@jmcombs/pi-context7 1.0.0 → 2.0.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 +40 -18
- package/index.ts +139 -268
- package/package.json +5 -4
- package/ui/bordered-popups.ts +0 -319
package/README.md
CHANGED
|
@@ -13,6 +13,33 @@
|
|
|
13
13
|
|
|
14
14
|
Real-time documentation for the Pi coding agent via [Context7](https://context7.com). Gives the agent access to up-to-date, version-aware docs and code examples without polluting context with outdated information.
|
|
15
15
|
|
|
16
|
+
## Breaking changes in v2.0.0
|
|
17
|
+
|
|
18
|
+
- **`AuthStorage` is gone.** Pi 0.80.8 removed the `AuthStorage` API this extension used to store and read its API key. Credentials now resolve through the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) **credential API**, which this package now depends on directly and **installs automatically** — no separate install step.
|
|
19
|
+
- **Availability-branched onboarding.** When the `op` CLI is installed and an account is configured, setup opens a 1Password **vault → item → field picker**; when `op` is unavailable it falls back to **masked manual key entry**.
|
|
20
|
+
- **Existing keys keep working.** Any Context7 key already in `~/.pi/agent/auth.json` — a literal value or an `!op read` reference — resolves unchanged. No migration action is required.
|
|
21
|
+
|
|
22
|
+
## What's New — 1Password credential integration
|
|
23
|
+
|
|
24
|
+
Context7 now handles your API key through the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) credential API, which installs automatically as a dependency. What this means for you:
|
|
25
|
+
|
|
26
|
+
- **Onboarding branches on 1Password availability.** If the `op` CLI is installed and an account is configured, `/context7_setup` opens a live **vault → item → field picker** (or lets you type an `op://…` reference) and stores it as a `!op read '…'` entry that resolves fresh on every use. If `op` is not available, it falls back to **manual API-key entry** and nudges you to enable the 1Password extension for vault integration.
|
|
27
|
+
- **Existing keys keep working.** Any Context7 key already in `~/.pi/agent/auth.json` — a literal key or an `!op read` reference — continues to resolve unchanged. No migration action is required.
|
|
28
|
+
- **The key is never exposed to the model.** Entry happens entirely in the TUI, and only the resolved value is used to call the Context7 API.
|
|
29
|
+
- **Startup warm-up.** With the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) extension installed and enabled, a one-time `op read` runs at session startup, so the 1Password biometric unlock prompt lands once and later key resolves are silent.
|
|
30
|
+
|
|
31
|
+
```mermaid
|
|
32
|
+
flowchart TD
|
|
33
|
+
A["/context7_setup or first tool use"] --> B{"is1PasswordAvailable()<br/>(op installed AND configured)"}
|
|
34
|
+
B -- "Yes" --> C["Live vault → item → field picker<br/>or manual op:// reference"]
|
|
35
|
+
C --> D["Store !op read 'op://…' entry"]
|
|
36
|
+
B -- "No" --> E["Manual API-key entry<br/>+ nudge to enable 1Password"]
|
|
37
|
+
E --> F["Store literal api_key entry"]
|
|
38
|
+
D --> G["resolveSecret('context7')<br/>resolves fresh on each tool call"]
|
|
39
|
+
F --> G
|
|
40
|
+
G --> H["Bearer token → Context7 API<br/>(never shown to the LLM)"]
|
|
41
|
+
```
|
|
42
|
+
|
|
16
43
|
## Quick Start
|
|
17
44
|
|
|
18
45
|
Get better library documentation in your agent in under a minute.
|
|
@@ -20,16 +47,16 @@ Get better library documentation in your agent in under a minute.
|
|
|
20
47
|
1. Install the extension:
|
|
21
48
|
|
|
22
49
|
```bash
|
|
23
|
-
pi install
|
|
50
|
+
pi install npm:@jmcombs/pi-context7
|
|
24
51
|
```
|
|
25
52
|
|
|
26
53
|
2. Configure your Context7 API key:
|
|
27
54
|
|
|
28
55
|
```
|
|
29
|
-
/
|
|
56
|
+
/context7_setup
|
|
30
57
|
```
|
|
31
58
|
|
|
32
|
-
The command opens
|
|
59
|
+
The command opens the onboarding flow above — a 1Password vault picker when `op` is available, or a secure manual-entry prompt otherwise. The value is never visible to the LLM.
|
|
33
60
|
|
|
34
61
|
After setup, just ask the agent for documentation normally:
|
|
35
62
|
|
|
@@ -46,28 +73,23 @@ This extension registers two tools:
|
|
|
46
73
|
- `context7_search` — Finds the correct Context7 library ID for a programming language, framework, or library.
|
|
47
74
|
- `context7_get_docs` — Retrieves detailed, version-specific documentation and real code examples for that library.
|
|
48
75
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- **Persisted keys** — Saved via `/context7_onboard` into `~/.pi/agent/auth.json` (supports plain keys and `!op read` references).
|
|
52
|
-
- **Runtime-only keys** — Entered ad-hoc when a tool is called and kept only for the current session (also supports `!op read` references).
|
|
53
|
-
|
|
54
|
-
This design lets you use Context7 without ever leaking keys into the LLM context.
|
|
76
|
+
Both tools resolve the key through `resolveSecret("context7")` from `@jmcombs/pi-1password`, reading `~/.pi/agent/auth.json` fresh on each call (a literal key or an `!op read` reference). If no key is stored, the tool automatically runs onboarding (the availability branch above), then re-resolves — preserving the "prompt on first use" experience. The key is never leaked into the LLM context.
|
|
55
77
|
|
|
56
|
-
## /
|
|
78
|
+
## /context7_setup
|
|
57
79
|
|
|
58
|
-
Run this command to
|
|
80
|
+
Run this command to configure (or update) your Context7 API key at any time:
|
|
59
81
|
|
|
60
82
|
```
|
|
61
|
-
/
|
|
83
|
+
/context7_setup
|
|
62
84
|
```
|
|
63
85
|
|
|
64
|
-
It
|
|
86
|
+
It delegates to the `@jmcombs/pi-1password` onboarding flow, which:
|
|
65
87
|
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
88
|
+
- Branches on 1Password availability — vault picker (`op://` reference) when `op` is configured, secure manual entry otherwise.
|
|
89
|
+
- Stores the value in `~/.pi/agent/auth.json` (`0600`), never exposing it to the model.
|
|
90
|
+
- Reports the outcome as a status notification.
|
|
69
91
|
|
|
70
|
-
|
|
92
|
+
To rotate an existing key, install and enable the [`@jmcombs/pi-1password`](https://www.npmjs.com/package/@jmcombs/pi-1password) extension and re-run onboarding through it, or edit `auth.json` directly.
|
|
71
93
|
|
|
72
94
|
## After Setup
|
|
73
95
|
|
|
@@ -81,7 +103,7 @@ Examples of good prompts:
|
|
|
81
103
|
|
|
82
104
|
## Checking Status
|
|
83
105
|
|
|
84
|
-
|
|
106
|
+
`/context7_setup` will not silently overwrite an existing key — if one is already stored it reports that and leaves it in place. To rotate a key, remove the existing `context7` entry from `~/.pi/agent/auth.json` (or update it through the `@jmcombs/pi-1password` extension), then run `/context7_setup` again.
|
|
85
107
|
|
|
86
108
|
## Development
|
|
87
109
|
|
package/index.ts
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers `context7_search` and `context7_get_docs` tools that let the LLM
|
|
5
5
|
* find and retrieve version-aware documentation and code snippets from the
|
|
6
|
-
* Context7 API.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Context7 API. Credentials are handled entirely through the imported
|
|
7
|
+
* `@jmcombs/pi-1password` credential API (`resolveSecret` / `onboardSecret`),
|
|
8
|
+
* so the key is never leaked into the agent's context.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* 1. `
|
|
12
|
-
*
|
|
10
|
+
* Credential handling:
|
|
11
|
+
* 1. `resolveSecret("context7")` reads `~/.pi/agent/auth.json` and resolves the
|
|
12
|
+
* stored entry (literal key or `!op read 'op://…'` reference) fresh on each use.
|
|
13
|
+
* 2. If nothing is stored, the tool auto-invokes `onboardSecret`, which branches
|
|
14
|
+
* on 1Password availability — the live vault picker when `op` is configured,
|
|
15
|
+
* manual API-key entry otherwise — then re-resolves.
|
|
16
|
+
* 3. `/context7_setup` runs the same onboarding flow on demand.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
19
|
+
import type { JsonObject, JsonValue } from "@earendil-works/pi-ai";
|
|
20
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { onboardSecret, resolveSecret } from "@jmcombs/pi-1password";
|
|
22
|
+
import { type Static, Type } from "typebox";
|
|
18
23
|
|
|
19
24
|
const CONTEXT7_API_BASE = "https://context7.com/api/v2";
|
|
20
25
|
|
|
@@ -29,22 +34,6 @@ interface InfoSnippet {
|
|
|
29
34
|
content?: string;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
interface Context7SearchResult {
|
|
33
|
-
id: string;
|
|
34
|
-
title: string;
|
|
35
|
-
[key: string]: unknown;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface Context7SearchResponse {
|
|
39
|
-
results?: Context7SearchResult[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface Context7DocsResponse {
|
|
43
|
-
codeSnippets?: CodeSnippet[];
|
|
44
|
-
infoSnippets?: InfoSnippet[];
|
|
45
|
-
[key: string]: unknown;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
37
|
// -- Tool parameter schemas
|
|
49
38
|
|
|
50
39
|
const context7SearchSchema = Type.Object({
|
|
@@ -71,11 +60,60 @@ export type Context7GetDocsInput = Static<typeof context7GetDocsSchema>;
|
|
|
71
60
|
|
|
72
61
|
// -- Helpers
|
|
73
62
|
|
|
74
|
-
function
|
|
75
|
-
|
|
63
|
+
export function isJsonValue(value: unknown): value is JsonValue {
|
|
64
|
+
if (value === null) return true;
|
|
65
|
+
switch (typeof value) {
|
|
66
|
+
case "boolean":
|
|
67
|
+
case "number":
|
|
68
|
+
case "string":
|
|
69
|
+
return true;
|
|
70
|
+
case "object": {
|
|
71
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
72
|
+
const nested: unknown[] = Object.values(value);
|
|
73
|
+
return nested.every(isJsonValue);
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function asJsonObject(value: JsonValue): JsonObject | undefined {
|
|
81
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
82
|
+
return value as JsonObject;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatDocs(data: JsonValue, query: string): string {
|
|
86
|
+
const obj = asJsonObject(data);
|
|
87
|
+
const codeSnippetsRaw = obj && Array.isArray(obj.codeSnippets) ? obj.codeSnippets : [];
|
|
88
|
+
const infoSnippetsRaw = obj && Array.isArray(obj.infoSnippets) ? obj.infoSnippets : [];
|
|
89
|
+
const codeSnippets: CodeSnippet[] = [];
|
|
90
|
+
for (const snippet of codeSnippetsRaw) {
|
|
91
|
+
const item = asJsonObject(snippet);
|
|
92
|
+
if (!item) continue;
|
|
93
|
+
const next: CodeSnippet = {};
|
|
94
|
+
if (typeof item.codeTitle === "string") next.codeTitle = item.codeTitle;
|
|
95
|
+
if (Array.isArray(item.codeList)) {
|
|
96
|
+
next.codeList = item.codeList.flatMap((entry) => {
|
|
97
|
+
const codeItem = asJsonObject(entry);
|
|
98
|
+
if (!codeItem) return [];
|
|
99
|
+
const language = typeof codeItem.language === "string" ? codeItem.language : undefined;
|
|
100
|
+
const code = typeof codeItem.code === "string" ? codeItem.code : undefined;
|
|
101
|
+
return [{ language, code }];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
codeSnippets.push(next);
|
|
105
|
+
}
|
|
106
|
+
const infoSnippets: InfoSnippet[] = [];
|
|
107
|
+
for (const snippet of infoSnippetsRaw) {
|
|
108
|
+
const item = asJsonObject(snippet);
|
|
109
|
+
if (!item) continue;
|
|
110
|
+
const next: InfoSnippet = {};
|
|
111
|
+
if (typeof item.content === "string") next.content = item.content;
|
|
112
|
+
infoSnippets.push(next);
|
|
113
|
+
}
|
|
76
114
|
|
|
77
115
|
if (codeSnippets.length === 0 && infoSnippets.length === 0) {
|
|
78
|
-
return
|
|
116
|
+
return `No documentation snippets found for ${query}.`;
|
|
79
117
|
}
|
|
80
118
|
|
|
81
119
|
const parts: string[] = [];
|
|
@@ -84,13 +122,13 @@ function formatDocs(data: Context7DocsResponse, query: string): string {
|
|
|
84
122
|
parts.push("--- CODE SNIPPETS ---");
|
|
85
123
|
for (const snippet of codeSnippets) {
|
|
86
124
|
if (snippet.codeTitle) {
|
|
87
|
-
parts.push(
|
|
125
|
+
parts.push(`\n## ${snippet.codeTitle}`);
|
|
88
126
|
}
|
|
89
127
|
if (snippet.codeList && snippet.codeList.length > 0) {
|
|
90
128
|
for (const item of snippet.codeList) {
|
|
91
129
|
if (item.code) {
|
|
92
130
|
const lang = item.language ?? "typescript";
|
|
93
|
-
parts.push(
|
|
131
|
+
parts.push(`\`\`\`${lang}\n${item.code}\n\`\`\`\n`);
|
|
94
132
|
}
|
|
95
133
|
}
|
|
96
134
|
}
|
|
@@ -101,57 +139,23 @@ function formatDocs(data: Context7DocsResponse, query: string): string {
|
|
|
101
139
|
parts.push("\n--- INFO SNIPPETS ---");
|
|
102
140
|
for (const snippet of infoSnippets) {
|
|
103
141
|
if (snippet.content) {
|
|
104
|
-
parts.push(
|
|
142
|
+
parts.push(`\n${snippet.content}`);
|
|
105
143
|
}
|
|
106
144
|
}
|
|
107
145
|
}
|
|
108
146
|
|
|
109
|
-
return
|
|
147
|
+
return `Documentation for ${query}:\n\n${parts.join("\n")}`;
|
|
110
148
|
}
|
|
111
149
|
|
|
112
150
|
// -- Extension factory
|
|
113
151
|
|
|
114
152
|
export default function (pi: ExtensionAPI): void {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
description: "Securely save your Context7 API key (input never visible to LLM).",
|
|
153
|
+
// -- /context7_setup (user-facing command)
|
|
154
|
+
pi.registerCommand("context7_setup", {
|
|
155
|
+
description: "Set up or update your Context7 API key (never shown to the agent).",
|
|
119
156
|
handler: async (_args, ctx) => {
|
|
120
|
-
ctx
|
|
121
|
-
|
|
122
|
-
const existing = await authStorage.getApiKey("context7");
|
|
123
|
-
if (existing) {
|
|
124
|
-
const overwrite = await confirmInBorderedPopup(ctx, {
|
|
125
|
-
title: "Context7 API key already exists, overwrite?",
|
|
126
|
-
message: "A Context7 API key is already saved. Overwrite it?",
|
|
127
|
-
});
|
|
128
|
-
if (!overwrite) {
|
|
129
|
-
ctx.ui.notify("Context7 onboarding cancelled.", "warning");
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const apiKey = await inputInBorderedPopup(ctx, {
|
|
135
|
-
title: "Context7 Onboarding",
|
|
136
|
-
prompt: "Enter your Context7 API key:",
|
|
137
|
-
helpText: "Enter to confirm • Esc = cancel",
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
if (!apiKey) {
|
|
141
|
-
ctx.ui.notify("Context7 onboarding cancelled.", "warning");
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
authStorage.set("context7", {
|
|
146
|
-
type: "api_key" as const,
|
|
147
|
-
key: apiKey,
|
|
148
|
-
});
|
|
149
|
-
authStorage.removeRuntimeApiKey("context7");
|
|
150
|
-
const errs = authStorage.drainErrors();
|
|
151
|
-
if (errs.length > 0) {
|
|
152
|
-
ctx.ui.notify(`onboard ERRORS: ${errs.map((e) => e.message).join("; ")}`, "warning");
|
|
153
|
-
}
|
|
154
|
-
ctx.ui.notify("Context7 API key saved successfully.", "info");
|
|
157
|
+
const result = await onboardSecret(ctx, { name: "context7", label: "Context7" });
|
|
158
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "warning");
|
|
155
159
|
},
|
|
156
160
|
});
|
|
157
161
|
|
|
@@ -165,97 +169,24 @@ export default function (pi: ExtensionAPI): void {
|
|
|
165
169
|
"Always prefer this tool over general web search when you need accurate, version-aware information for coding or development tasks.",
|
|
166
170
|
parameters: context7SearchSchema,
|
|
167
171
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
168
|
-
let apiKey = await
|
|
169
|
-
|
|
172
|
+
let apiKey = await resolveSecret("context7");
|
|
170
173
|
if (!apiKey) {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
helpText: "Enter to confirm • Esc = cancel",
|
|
175
|
-
});
|
|
176
|
-
if (!entered) {
|
|
177
|
-
ctx.ui.notify("Context7 search cancelled.", "warning");
|
|
178
|
-
return {
|
|
179
|
-
content: [
|
|
180
|
-
{
|
|
181
|
-
type: "text",
|
|
182
|
-
text: "Search cancelled: no Context7 API key provided.",
|
|
183
|
-
},
|
|
184
|
-
],
|
|
185
|
-
details: { error: "missing_api_key" },
|
|
186
|
-
isError: true,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Always use the entered value for this request (supports raw keys and !op read)
|
|
191
|
-
apiKey = entered;
|
|
192
|
-
|
|
193
|
-
const savePermanently = await confirmInBorderedPopup(ctx, {
|
|
194
|
-
title: "Save API Key?",
|
|
195
|
-
message:
|
|
196
|
-
"Save this value permanently in auth.json?\n\n" +
|
|
197
|
-
"Note: 1Password references (!op read ...) only resolve when saved permanently. " +
|
|
198
|
-
"Choosing No will store the resolved secret for this session only.",
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
if (savePermanently === null) {
|
|
202
|
-
// User cancelled the confirmation dialog — abort the entire key entry
|
|
203
|
-
ctx.ui.notify("Context7 authentication cancelled.", "warning");
|
|
204
|
-
return {
|
|
205
|
-
content: [
|
|
206
|
-
{
|
|
207
|
-
type: "text",
|
|
208
|
-
text:
|
|
209
|
-
"The user explicitly cancelled Context7 authentication. " +
|
|
210
|
-
"Do not attempt to use context7_search or context7_get_docs again in this session " +
|
|
211
|
-
"without the user re-initiating the flow.",
|
|
212
|
-
},
|
|
213
|
-
],
|
|
214
|
-
details: { error: "cancelled" },
|
|
215
|
-
isError: true,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (savePermanently) {
|
|
220
|
-
authStorage.set("context7", {
|
|
221
|
-
type: "api_key" as const,
|
|
222
|
-
key: entered,
|
|
223
|
-
});
|
|
224
|
-
authStorage.removeRuntimeApiKey("context7");
|
|
225
|
-
const errs = authStorage.drainErrors();
|
|
226
|
-
if (errs.length > 0) {
|
|
227
|
-
ctx.ui.notify(
|
|
228
|
-
`[context7] search save ERRORS: ${errs.map((e) => e.message).join("; ")}`,
|
|
229
|
-
"warning",
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
let keyForRuntime = entered;
|
|
234
|
-
if (entered.trim().startsWith("!op read")) {
|
|
235
|
-
// Temporarily store the reference so AuthStorage resolves it via the normal path (1Password, etc.)
|
|
236
|
-
authStorage.set("context7", { type: "api_key" as const, key: entered });
|
|
237
|
-
const resolved = await authStorage.getApiKey("context7");
|
|
238
|
-
authStorage.remove("context7");
|
|
239
|
-
if (resolved) {
|
|
240
|
-
keyForRuntime = resolved;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
authStorage.setRuntimeApiKey("context7", keyForRuntime);
|
|
244
|
-
}
|
|
245
|
-
apiKey = (await authStorage.getApiKey("context7")) ?? entered;
|
|
246
|
-
if (!apiKey) {
|
|
247
|
-
return {
|
|
248
|
-
content: [
|
|
249
|
-
{
|
|
250
|
-
type: "text",
|
|
251
|
-
text: "Failed to resolve Context7 API key. Check your shell configuration.",
|
|
252
|
-
},
|
|
253
|
-
],
|
|
254
|
-
details: { error: "missing_api_key" },
|
|
255
|
-
isError: true,
|
|
256
|
-
};
|
|
174
|
+
const r = await onboardSecret(ctx, { name: "context7", label: "Context7" });
|
|
175
|
+
if (r.ok) {
|
|
176
|
+
apiKey = await resolveSecret("context7");
|
|
257
177
|
}
|
|
258
178
|
}
|
|
179
|
+
if (!apiKey) {
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
{
|
|
183
|
+
type: "text",
|
|
184
|
+
text: "Search cancelled: no Context7 API key provided.",
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
details: { error: "missing_api_key" },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
259
190
|
|
|
260
191
|
try {
|
|
261
192
|
const url = new URL("/api/v2/libs/search", CONTEXT7_API_BASE);
|
|
@@ -266,7 +197,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
266
197
|
|
|
267
198
|
const response = await fetch(url.toString(), {
|
|
268
199
|
signal,
|
|
269
|
-
headers: { Authorization:
|
|
200
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
270
201
|
});
|
|
271
202
|
|
|
272
203
|
if (!response.ok) {
|
|
@@ -277,11 +208,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
277
208
|
type: "text",
|
|
278
209
|
text:
|
|
279
210
|
"Context7 API error: 401 Unauthorized. Your Context7 API key " +
|
|
280
|
-
"may be missing or invalid. Run /
|
|
211
|
+
"may be missing or invalid. Run /context7_setup to configure it.",
|
|
281
212
|
},
|
|
282
213
|
],
|
|
283
214
|
details: { status: 401 },
|
|
284
|
-
isError: true,
|
|
285
215
|
};
|
|
286
216
|
}
|
|
287
217
|
if (response.status === 429) {
|
|
@@ -295,7 +225,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
295
225
|
},
|
|
296
226
|
],
|
|
297
227
|
details: { status: 429 },
|
|
298
|
-
isError: true,
|
|
299
228
|
};
|
|
300
229
|
}
|
|
301
230
|
|
|
@@ -314,19 +243,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
314
243
|
},
|
|
315
244
|
],
|
|
316
245
|
details: { status: response.status, body: errorText },
|
|
317
|
-
isError: true,
|
|
318
246
|
};
|
|
319
247
|
}
|
|
320
248
|
|
|
321
|
-
const
|
|
322
|
-
|
|
249
|
+
const parsed: unknown = await response.json();
|
|
250
|
+
if (!isJsonValue(parsed)) {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: "Context7 API returned invalid JSON." }],
|
|
253
|
+
details: { error: "invalid_json" },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const data = parsed;
|
|
257
|
+
const obj = asJsonObject(data);
|
|
258
|
+
const libsRaw = obj && Array.isArray(obj.results) ? obj.results : [];
|
|
259
|
+
const libs: { id: string; title: string }[] = [];
|
|
260
|
+
for (const lib of libsRaw) {
|
|
261
|
+
const item = asJsonObject(lib);
|
|
262
|
+
if (!item) continue;
|
|
263
|
+
if (typeof item.id === "string" && typeof item.title === "string") {
|
|
264
|
+
libs.push({ id: item.id, title: item.title });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
323
267
|
|
|
324
268
|
if (libs.length === 0) {
|
|
325
269
|
return {
|
|
326
270
|
content: [
|
|
327
271
|
{
|
|
328
272
|
type: "text",
|
|
329
|
-
text:
|
|
273
|
+
text: `No libraries found matching ${params.libraryName}.`,
|
|
330
274
|
},
|
|
331
275
|
],
|
|
332
276
|
details: { libraryName: params.libraryName, raw: data },
|
|
@@ -334,9 +278,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
334
278
|
}
|
|
335
279
|
|
|
336
280
|
const formatted = libs
|
|
337
|
-
.map(
|
|
338
|
-
return String(i + 1) + ". " + lib.title + " (ID: " + lib.id + ")";
|
|
339
|
-
})
|
|
281
|
+
.map((lib, i) => `${String(i + 1)}. ${lib.title} (ID: ${lib.id})`)
|
|
340
282
|
.join("\n");
|
|
341
283
|
|
|
342
284
|
return {
|
|
@@ -359,11 +301,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
359
301
|
content: [
|
|
360
302
|
{
|
|
361
303
|
type: "text",
|
|
362
|
-
text:
|
|
304
|
+
text: `Error performing Context7 search: ${message}`,
|
|
363
305
|
},
|
|
364
306
|
],
|
|
365
307
|
details: { error: message },
|
|
366
|
-
isError: true,
|
|
367
308
|
};
|
|
368
309
|
}
|
|
369
310
|
},
|
|
@@ -379,97 +320,24 @@ export default function (pi: ExtensionAPI): void {
|
|
|
379
320
|
"You should usually call context7_search first to obtain the correct Library ID. Prefer this tool when you need reliable, current technical documentation rather than general explanations.",
|
|
380
321
|
parameters: context7GetDocsSchema,
|
|
381
322
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
382
|
-
let apiKey = await
|
|
383
|
-
|
|
323
|
+
let apiKey = await resolveSecret("context7");
|
|
384
324
|
if (!apiKey) {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
helpText: "Enter to confirm • Esc = cancel",
|
|
389
|
-
});
|
|
390
|
-
if (!entered) {
|
|
391
|
-
ctx.ui.notify("Context7 documentation retrieval cancelled.", "warning");
|
|
392
|
-
return {
|
|
393
|
-
content: [
|
|
394
|
-
{
|
|
395
|
-
type: "text",
|
|
396
|
-
text: "Documentation retrieval cancelled: no Context7 API key provided.",
|
|
397
|
-
},
|
|
398
|
-
],
|
|
399
|
-
details: { error: "missing_api_key" },
|
|
400
|
-
isError: true,
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Always use the entered value for this request (supports raw keys and !op read)
|
|
405
|
-
apiKey = entered;
|
|
406
|
-
|
|
407
|
-
const savePermanently = await confirmInBorderedPopup(ctx, {
|
|
408
|
-
title: "Save API Key?",
|
|
409
|
-
message:
|
|
410
|
-
"Save this value permanently in auth.json?\n\n" +
|
|
411
|
-
"Note: 1Password references (!op read ...) only resolve when saved permanently. " +
|
|
412
|
-
"Choosing No will store the resolved secret for this session only.",
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
if (savePermanently === null) {
|
|
416
|
-
// User cancelled the confirmation dialog — abort the entire key entry
|
|
417
|
-
ctx.ui.notify("Context7 authentication cancelled.", "warning");
|
|
418
|
-
return {
|
|
419
|
-
content: [
|
|
420
|
-
{
|
|
421
|
-
type: "text",
|
|
422
|
-
text:
|
|
423
|
-
"The user explicitly cancelled Context7 authentication. " +
|
|
424
|
-
"Do not attempt to use context7_search or context7_get_docs again in this session " +
|
|
425
|
-
"without the user re-initiating the flow.",
|
|
426
|
-
},
|
|
427
|
-
],
|
|
428
|
-
details: { error: "cancelled" },
|
|
429
|
-
isError: true,
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (savePermanently) {
|
|
434
|
-
authStorage.set("context7", {
|
|
435
|
-
type: "api_key" as const,
|
|
436
|
-
key: entered,
|
|
437
|
-
});
|
|
438
|
-
authStorage.removeRuntimeApiKey("context7");
|
|
439
|
-
const errs = authStorage.drainErrors();
|
|
440
|
-
if (errs.length > 0) {
|
|
441
|
-
ctx.ui.notify(
|
|
442
|
-
`get_docs save ERRORS: ${errs.map((e) => e.message).join("; ")}`,
|
|
443
|
-
"warning",
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
} else {
|
|
447
|
-
let keyForRuntime = entered;
|
|
448
|
-
if (entered.trim().startsWith("!op read")) {
|
|
449
|
-
// Temporarily store the reference so AuthStorage resolves it via the normal path (1Password, etc.)
|
|
450
|
-
authStorage.set("context7", { type: "api_key" as const, key: entered });
|
|
451
|
-
const resolved = await authStorage.getApiKey("context7");
|
|
452
|
-
authStorage.remove("context7");
|
|
453
|
-
if (resolved) {
|
|
454
|
-
keyForRuntime = resolved;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
authStorage.setRuntimeApiKey("context7", keyForRuntime);
|
|
458
|
-
}
|
|
459
|
-
apiKey = (await authStorage.getApiKey("context7")) ?? entered;
|
|
460
|
-
if (!apiKey) {
|
|
461
|
-
return {
|
|
462
|
-
content: [
|
|
463
|
-
{
|
|
464
|
-
type: "text",
|
|
465
|
-
text: "Failed to resolve Context7 API key. Check your shell configuration.",
|
|
466
|
-
},
|
|
467
|
-
],
|
|
468
|
-
details: { error: "missing_api_key" },
|
|
469
|
-
isError: true,
|
|
470
|
-
};
|
|
325
|
+
const r = await onboardSecret(ctx, { name: "context7", label: "Context7" });
|
|
326
|
+
if (r.ok) {
|
|
327
|
+
apiKey = await resolveSecret("context7");
|
|
471
328
|
}
|
|
472
329
|
}
|
|
330
|
+
if (!apiKey) {
|
|
331
|
+
return {
|
|
332
|
+
content: [
|
|
333
|
+
{
|
|
334
|
+
type: "text",
|
|
335
|
+
text: "Documentation retrieval cancelled: no Context7 API key provided.",
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
details: { error: "missing_api_key" },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
473
341
|
|
|
474
342
|
try {
|
|
475
343
|
const url = new URL("/api/v2/context", CONTEXT7_API_BASE);
|
|
@@ -479,7 +347,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
479
347
|
|
|
480
348
|
const response = await fetch(url.toString(), {
|
|
481
349
|
signal,
|
|
482
|
-
headers: { Authorization:
|
|
350
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
483
351
|
});
|
|
484
352
|
|
|
485
353
|
if (!response.ok) {
|
|
@@ -490,11 +358,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
490
358
|
type: "text",
|
|
491
359
|
text:
|
|
492
360
|
"Context7 API error: 401 Unauthorized. Your Context7 API key " +
|
|
493
|
-
"may be missing or invalid. Run /
|
|
361
|
+
"may be missing or invalid. Run /context7_setup to configure it.",
|
|
494
362
|
},
|
|
495
363
|
],
|
|
496
364
|
details: { status: 401 },
|
|
497
|
-
isError: true,
|
|
498
365
|
};
|
|
499
366
|
}
|
|
500
367
|
if (response.status === 429) {
|
|
@@ -508,7 +375,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
508
375
|
},
|
|
509
376
|
],
|
|
510
377
|
details: { status: 429 },
|
|
511
|
-
isError: true,
|
|
512
378
|
};
|
|
513
379
|
}
|
|
514
380
|
|
|
@@ -527,11 +393,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
527
393
|
},
|
|
528
394
|
],
|
|
529
395
|
details: { status: response.status, body: errorText },
|
|
530
|
-
isError: true,
|
|
531
396
|
};
|
|
532
397
|
}
|
|
533
398
|
|
|
534
|
-
const
|
|
399
|
+
const parsed: unknown = await response.json();
|
|
400
|
+
if (!isJsonValue(parsed)) {
|
|
401
|
+
return {
|
|
402
|
+
content: [{ type: "text", text: "Context7 API returned invalid JSON." }],
|
|
403
|
+
details: { error: "invalid_json" },
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const data = parsed;
|
|
535
407
|
return {
|
|
536
408
|
content: [{ type: "text", text: formatDocs(data, params.query) }],
|
|
537
409
|
details: { libraryId: params.libraryId, query: params.query, raw: data },
|
|
@@ -542,11 +414,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
542
414
|
content: [
|
|
543
415
|
{
|
|
544
416
|
type: "text",
|
|
545
|
-
text:
|
|
417
|
+
text: `Error fetching Context7 documentation: ${message}`,
|
|
546
418
|
},
|
|
547
419
|
],
|
|
548
420
|
details: { error: message },
|
|
549
|
-
isError: true,
|
|
550
421
|
};
|
|
551
422
|
}
|
|
552
423
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jmcombs/pi-context7",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Real-time documentation for the Pi coding agent via Context7.",
|
|
6
6
|
"homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/context7",
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"types": "./index.ts",
|
|
20
20
|
"files": [
|
|
21
21
|
"index.ts",
|
|
22
|
-
"ui/",
|
|
23
22
|
"README.md",
|
|
24
23
|
"LICENSE"
|
|
25
24
|
],
|
|
@@ -35,11 +34,13 @@
|
|
|
35
34
|
"image": "https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/context7/preview.png"
|
|
36
35
|
},
|
|
37
36
|
"engines": {
|
|
38
|
-
"node": ">=22.
|
|
37
|
+
"node": ">=22.19.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@jmcombs/pi-1password": "^1.0.2 || ^2.0.0"
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
|
41
43
|
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
-
"@earendil-works/pi-tui": "*",
|
|
43
44
|
"typebox": "*"
|
|
44
45
|
}
|
|
45
46
|
}
|
package/ui/bordered-popups.ts
DELETED
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bordered Popup TUI Helpers
|
|
3
|
-
*
|
|
4
|
-
* These are reusable, self-contained helpers for creating polished,
|
|
5
|
-
* consistent bordered popups inside Pi extensions using `ctx.ui.custom({ overlay: true })`.
|
|
6
|
-
*
|
|
7
|
-
* They were originally developed in the 1Password extension and are provided
|
|
8
|
-
* here as a copy-paste starting point for any extension that needs rich
|
|
9
|
-
* interactive flows (select lists with live filtering, text input, confirms)
|
|
10
|
-
* that look and feel better than the basic `ctx.ui.select / input / confirm`.
|
|
11
|
-
*
|
|
12
|
-
* ## Usage
|
|
13
|
-
*
|
|
14
|
-
* ```ts
|
|
15
|
-
* import {
|
|
16
|
-
* selectInBorderedPopup,
|
|
17
|
-
* confirmInBorderedPopup,
|
|
18
|
-
* inputInBorderedPopup,
|
|
19
|
-
* } from "./ui/bordered-popups.js";
|
|
20
|
-
*
|
|
21
|
-
* const choice = await selectInBorderedPopup(ctx, {
|
|
22
|
-
* title: "Select something",
|
|
23
|
-
* items: [...],
|
|
24
|
-
* });
|
|
25
|
-
*
|
|
26
|
-
* const confirmed = await confirmInBorderedPopup(ctx, {
|
|
27
|
-
* title: "Are you sure?",
|
|
28
|
-
* });
|
|
29
|
-
*
|
|
30
|
-
* const name = await inputInBorderedPopup(ctx, {
|
|
31
|
-
* title: "Enter name",
|
|
32
|
-
* prompt: "What should we call it?",
|
|
33
|
-
* });
|
|
34
|
-
* ```
|
|
35
|
-
*
|
|
36
|
-
* The helpers automatically handle:
|
|
37
|
-
* - Consistent 4-sided borders (╭─╮│╰╯) with stable right edge
|
|
38
|
-
* - Proper ANSI-aware padding using Pi's `truncateToWidth`
|
|
39
|
-
* - Live filtering on long lists
|
|
40
|
-
* - Back navigation ("← Go back") and Esc-to-cancel semantics
|
|
41
|
-
* - Theming consistent with the rest of Pi
|
|
42
|
-
*
|
|
43
|
-
* ## When to use
|
|
44
|
-
*
|
|
45
|
-
* Use these when you have:
|
|
46
|
-
* - Long lists that benefit from filtering
|
|
47
|
-
* - Multi-step wizards
|
|
48
|
-
* - Situations where the basic Pi UI dialogs feel too plain
|
|
49
|
-
*
|
|
50
|
-
* For very simple one-off prompts, the built-in `ctx.ui.select/input/confirm`
|
|
51
|
-
* are still perfectly acceptable and require less code.
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
|
-
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
55
|
-
|
|
56
|
-
// No static imports from @earendil-works/pi-tui are used for types.
|
|
57
|
-
// We rely on inference for ctx.ui.custom callback parameters (sourced via the
|
|
58
|
-
// pi-coding-agent peer) and a minimal local facade for the runtime values
|
|
59
|
-
// obtained via dynamic import. This avoids duplicate module declarations
|
|
60
|
-
// (pi-tui types nested inside coding-agent vs. direct peer) that would
|
|
61
|
-
// otherwise break tsc strict under the project's monorepo layout.
|
|
62
|
-
|
|
63
|
-
/** Internal helper to render a consistent bordered box. */
|
|
64
|
-
export function renderBorderedBox(
|
|
65
|
-
width: number,
|
|
66
|
-
title: string,
|
|
67
|
-
bodyLines: string[],
|
|
68
|
-
footer: string | undefined,
|
|
69
|
-
theme: Pick<Theme, "fg" | "bold">,
|
|
70
|
-
truncateToWidthFn: (s: string, w: number, e?: string, pad?: boolean) => string,
|
|
71
|
-
): string[] {
|
|
72
|
-
const innerWidth = Math.max(20, width - 4);
|
|
73
|
-
const top = theme.fg("accent", "╭" + "─".repeat(width - 2) + "╮");
|
|
74
|
-
const bottom = theme.fg("accent", "╰" + "─".repeat(width - 2) + "╯");
|
|
75
|
-
|
|
76
|
-
const rawTitle = theme.fg("accent", theme.bold(title));
|
|
77
|
-
const titlePadded = truncateToWidthFn(rawTitle, innerWidth, "", true);
|
|
78
|
-
const borderedTitle = theme.fg("accent", "│ ") + titlePadded + theme.fg("accent", " │");
|
|
79
|
-
|
|
80
|
-
const borderedBody = bodyLines.map((line) => {
|
|
81
|
-
const padded = truncateToWidthFn(line || "", innerWidth, "", true);
|
|
82
|
-
return theme.fg("accent", "│ ") + padded + theme.fg("accent", " │");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const lines = [top, borderedTitle, ...borderedBody];
|
|
86
|
-
|
|
87
|
-
if (footer) {
|
|
88
|
-
const rawFooter = theme.fg("dim", footer);
|
|
89
|
-
const footerPadded = truncateToWidthFn(rawFooter, innerWidth, "", true);
|
|
90
|
-
lines.push(theme.fg("accent", "│ ") + footerPadded + theme.fg("accent", " │"));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
lines.push(bottom);
|
|
94
|
-
return lines;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* High-level helper for a filterable list inside a bordered popup.
|
|
99
|
-
* Returns the chosen `.value` or `null` (on cancel / Esc).
|
|
100
|
-
*/
|
|
101
|
-
export async function selectInBorderedPopup<T = string>(
|
|
102
|
-
ctx: ExtensionContext,
|
|
103
|
-
opts: {
|
|
104
|
-
title: string;
|
|
105
|
-
items: { value: T; label: string; description?: string }[];
|
|
106
|
-
helpText?: string;
|
|
107
|
-
maxVisible?: number;
|
|
108
|
-
},
|
|
109
|
-
): Promise<T | null> {
|
|
110
|
-
const maxVis = opts.maxVisible ?? 14;
|
|
111
|
-
const help = opts.helpText ?? "↑↓ • Enter • Esc = cancel • Type to filter";
|
|
112
|
-
|
|
113
|
-
// Pure local interface (no `extends` of the real SelectList type) describing
|
|
114
|
-
// exactly the surface we use. Avoids pulling in conflicting .d.ts copies of
|
|
115
|
-
// pi-tui that exist via the coding-agent transitive dep vs. our direct peer.
|
|
116
|
-
interface SelectListHandle {
|
|
117
|
-
render(w: number): string[];
|
|
118
|
-
invalidate(): void;
|
|
119
|
-
handleInput(d: string): void;
|
|
120
|
-
onSelect: (item: { value: T }) => void;
|
|
121
|
-
onCancel: () => void;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Let inference provide the exact callback parameter types from the
|
|
125
|
-
// ExtensionCommandContext (via coding-agent peer). Explicit annotations
|
|
126
|
-
// referencing TUI/KeybindingsManager etc. from pi-tui trigger the
|
|
127
|
-
// "separate declarations of private property" tsc error in the monorepo.
|
|
128
|
-
return await ctx.ui.custom<T | null>(
|
|
129
|
-
async (tui, theme, _kb, done) => {
|
|
130
|
-
const piTui = (await import("@earendil-works/pi-tui")) as unknown as {
|
|
131
|
-
SelectList: new (
|
|
132
|
-
items: { value: T; label: string; description?: string }[],
|
|
133
|
-
maxVisible: number,
|
|
134
|
-
theme: unknown,
|
|
135
|
-
) => SelectListHandle;
|
|
136
|
-
Container: new () => { invalidate(): void };
|
|
137
|
-
truncateToWidth: (s: string, w: number, e?: string, pad?: boolean) => string;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const { SelectList, Container, truncateToWidth: truncateToWidthFn } = piTui;
|
|
141
|
-
|
|
142
|
-
let currentList: SelectListHandle | null = null;
|
|
143
|
-
|
|
144
|
-
function build() {
|
|
145
|
-
currentList = new SelectList(
|
|
146
|
-
opts.items.map((it) => ({
|
|
147
|
-
value: it.value,
|
|
148
|
-
label: it.label,
|
|
149
|
-
description: it.description,
|
|
150
|
-
})),
|
|
151
|
-
maxVis,
|
|
152
|
-
{
|
|
153
|
-
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
154
|
-
selectedText: (t: string) => theme.fg("accent", t),
|
|
155
|
-
description: (t: string) => theme.fg("muted", t),
|
|
156
|
-
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
157
|
-
noMatch: (t: string) => theme.fg("warning", t),
|
|
158
|
-
},
|
|
159
|
-
);
|
|
160
|
-
currentList.onSelect = (item) => {
|
|
161
|
-
done(item.value);
|
|
162
|
-
};
|
|
163
|
-
currentList.onCancel = () => {
|
|
164
|
-
done(null);
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
build();
|
|
169
|
-
|
|
170
|
-
const container = new Container();
|
|
171
|
-
|
|
172
|
-
const popup: {
|
|
173
|
-
render(w: number): string[];
|
|
174
|
-
invalidate(): void;
|
|
175
|
-
handleInput?(d: string): void;
|
|
176
|
-
dispose?(): void;
|
|
177
|
-
} = {
|
|
178
|
-
render(width: number) {
|
|
179
|
-
const listLines = currentList ? currentList.render(Math.max(20, width - 4)) : [];
|
|
180
|
-
return renderBorderedBox(width, opts.title, listLines, help, theme, truncateToWidthFn);
|
|
181
|
-
},
|
|
182
|
-
invalidate() {
|
|
183
|
-
container.invalidate();
|
|
184
|
-
currentList?.invalidate();
|
|
185
|
-
},
|
|
186
|
-
handleInput(d: string) {
|
|
187
|
-
currentList?.handleInput(d);
|
|
188
|
-
tui.requestRender();
|
|
189
|
-
},
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
return popup;
|
|
193
|
-
},
|
|
194
|
-
{ overlay: true },
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Yes/No (or custom labels) confirmation inside a bordered popup. */
|
|
199
|
-
export async function confirmInBorderedPopup(
|
|
200
|
-
ctx: ExtensionContext,
|
|
201
|
-
opts: {
|
|
202
|
-
title: string;
|
|
203
|
-
message?: string;
|
|
204
|
-
confirmLabel?: string;
|
|
205
|
-
cancelLabel?: string;
|
|
206
|
-
},
|
|
207
|
-
): Promise<boolean | null> {
|
|
208
|
-
const yes = opts.confirmLabel ?? "Yes";
|
|
209
|
-
const no = opts.cancelLabel ?? "No";
|
|
210
|
-
const items = [
|
|
211
|
-
{ value: true, label: yes },
|
|
212
|
-
{ value: false, label: no },
|
|
213
|
-
];
|
|
214
|
-
|
|
215
|
-
const choice = await selectInBorderedPopup(ctx, {
|
|
216
|
-
title: opts.title,
|
|
217
|
-
items,
|
|
218
|
-
helpText: "↑↓ • Enter to confirm • Esc = cancel",
|
|
219
|
-
maxVisible: 5,
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
if (choice === null) return null; // user cancelled the dialog
|
|
223
|
-
return choice;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Bordered popup text input powered by Pi's Editor component.
|
|
228
|
-
* Good for free-text entry while staying inside the custom popup aesthetic.
|
|
229
|
-
*/
|
|
230
|
-
export async function inputInBorderedPopup(
|
|
231
|
-
ctx: ExtensionContext,
|
|
232
|
-
opts: {
|
|
233
|
-
title: string;
|
|
234
|
-
prompt?: string;
|
|
235
|
-
defaultValue?: string;
|
|
236
|
-
helpText?: string;
|
|
237
|
-
},
|
|
238
|
-
): Promise<string | undefined> {
|
|
239
|
-
const help = opts.helpText ?? "Enter to confirm • Esc = cancel";
|
|
240
|
-
|
|
241
|
-
// Pure local interface (no extends) for the submit hook.
|
|
242
|
-
interface EditorHandle {
|
|
243
|
-
render(w: number): string[];
|
|
244
|
-
invalidate(): void;
|
|
245
|
-
handleInput(d: string): void;
|
|
246
|
-
setText(s: string): void;
|
|
247
|
-
onSubmit: (value: string) => void;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Inference for callback params (see selectInBorderedPopup for rationale).
|
|
251
|
-
return await ctx.ui.custom<string | undefined>(
|
|
252
|
-
async (tui, theme, _kb, done) => {
|
|
253
|
-
const piTui = (await import("@earendil-works/pi-tui")) as unknown as {
|
|
254
|
-
Editor: new (tui: unknown, theme: unknown) => EditorHandle;
|
|
255
|
-
matchesKey: (data: string, key: string) => boolean;
|
|
256
|
-
truncateToWidth: (s: string, w: number, e?: string, pad?: boolean) => string;
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
const { Editor, matchesKey, truncateToWidth: truncateToWidthFn } = piTui;
|
|
260
|
-
|
|
261
|
-
const editorTheme = {
|
|
262
|
-
borderColor: (s: string) => theme.fg("accent", s),
|
|
263
|
-
selectList: {
|
|
264
|
-
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
265
|
-
selectedText: (t: string) => theme.fg("accent", t),
|
|
266
|
-
description: (t: string) => theme.fg("muted", t),
|
|
267
|
-
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
268
|
-
noMatch: (t: string) => theme.fg("warning", t),
|
|
269
|
-
},
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const editor = new Editor(tui, editorTheme);
|
|
273
|
-
|
|
274
|
-
if (opts.defaultValue) {
|
|
275
|
-
editor.setText(opts.defaultValue);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
editor.onSubmit = (value: string) => {
|
|
279
|
-
done(value.trim() || undefined);
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const popup: {
|
|
283
|
-
render(w: number): string[];
|
|
284
|
-
invalidate(): void;
|
|
285
|
-
handleInput?(d: string): void;
|
|
286
|
-
dispose?(): void;
|
|
287
|
-
} = {
|
|
288
|
-
render(width: number) {
|
|
289
|
-
const innerWidth = Math.max(20, width - 4);
|
|
290
|
-
const body: string[] = [];
|
|
291
|
-
|
|
292
|
-
if (opts.prompt) {
|
|
293
|
-
body.push(theme.fg("text", opts.prompt));
|
|
294
|
-
body.push("");
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const editorLines = editor.render(innerWidth);
|
|
298
|
-
body.push(...editorLines);
|
|
299
|
-
|
|
300
|
-
return renderBorderedBox(width, opts.title, body, help, theme, truncateToWidthFn);
|
|
301
|
-
},
|
|
302
|
-
invalidate() {
|
|
303
|
-
editor.invalidate();
|
|
304
|
-
},
|
|
305
|
-
handleInput(data: string) {
|
|
306
|
-
if (matchesKey(data, "escape")) {
|
|
307
|
-
done(undefined);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
editor.handleInput(data);
|
|
311
|
-
tui.requestRender();
|
|
312
|
-
},
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
return popup;
|
|
316
|
-
},
|
|
317
|
-
{ overlay: true },
|
|
318
|
-
);
|
|
319
|
-
}
|