@jmcombs/pi-1password 1.0.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/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@jmcombs/pi-1password",
3
+ "version": "1.0.0",
4
+ "description": "1Password integration for the Pi coding agent. Read secrets and run commands with 1Password credential injection via the op CLI.",
5
+ "homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/1password",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jmcombs/pi-extensions.git",
9
+ "directory": "packages/1password"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/jmcombs/pi-extensions/issues"
13
+ },
14
+ "license": "MIT",
15
+ "author": "Jeremy Combs",
16
+ "type": "module",
17
+ "main": "./index.ts",
18
+ "types": "./index.ts",
19
+ "files": [
20
+ "index.ts",
21
+ "data/",
22
+ "ui/",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "pi-package",
28
+ "pi-extension",
29
+ "1password",
30
+ "op",
31
+ "credentials",
32
+ "secrets"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./index.ts"
37
+ ],
38
+ "image": "https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/1password/preview.png"
39
+ },
40
+ "engines": {
41
+ "node": ">=22.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "typebox": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@earendil-works/pi-coding-agent": "^0.75.4",
50
+ "@earendil-works/pi-tui": "*",
51
+ "typebox": "^1.1.37"
52
+ }
53
+ }
@@ -0,0 +1,318 @@
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 { ExtensionCommandContext, 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: ExtensionCommandContext,
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: ExtensionCommandContext,
201
+ opts: {
202
+ title: string;
203
+ message?: string;
204
+ confirmLabel?: string;
205
+ cancelLabel?: string;
206
+ },
207
+ ): Promise<boolean> {
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
+ return choice === true;
223
+ }
224
+
225
+ /**
226
+ * Bordered popup text input powered by Pi's Editor component.
227
+ * Good for free-text entry while staying inside the custom popup aesthetic.
228
+ */
229
+ export async function inputInBorderedPopup(
230
+ ctx: ExtensionCommandContext,
231
+ opts: {
232
+ title: string;
233
+ prompt?: string;
234
+ defaultValue?: string;
235
+ helpText?: string;
236
+ },
237
+ ): Promise<string | undefined> {
238
+ const help = opts.helpText ?? "Enter to confirm • Esc = cancel";
239
+
240
+ // Pure local interface (no extends) for the submit hook.
241
+ interface EditorHandle {
242
+ render(w: number): string[];
243
+ invalidate(): void;
244
+ handleInput(d: string): void;
245
+ setText(s: string): void;
246
+ onSubmit: (value: string) => void;
247
+ }
248
+
249
+ // Inference for callback params (see selectInBorderedPopup for rationale).
250
+ return await ctx.ui.custom<string | undefined>(
251
+ async (tui, theme, _kb, done) => {
252
+ const piTui = (await import("@earendil-works/pi-tui")) as unknown as {
253
+ Editor: new (tui: unknown, theme: unknown) => EditorHandle;
254
+ matchesKey: (data: string, key: string) => boolean;
255
+ truncateToWidth: (s: string, w: number, e?: string, pad?: boolean) => string;
256
+ };
257
+
258
+ const { Editor, matchesKey, truncateToWidth: truncateToWidthFn } = piTui;
259
+
260
+ const editorTheme = {
261
+ borderColor: (s: string) => theme.fg("accent", s),
262
+ selectList: {
263
+ selectedPrefix: (t: string) => theme.fg("accent", t),
264
+ selectedText: (t: string) => theme.fg("accent", t),
265
+ description: (t: string) => theme.fg("muted", t),
266
+ scrollInfo: (t: string) => theme.fg("dim", t),
267
+ noMatch: (t: string) => theme.fg("warning", t),
268
+ },
269
+ };
270
+
271
+ const editor = new Editor(tui, editorTheme);
272
+
273
+ if (opts.defaultValue) {
274
+ editor.setText(opts.defaultValue);
275
+ }
276
+
277
+ editor.onSubmit = (value: string) => {
278
+ done(value.trim() || undefined);
279
+ };
280
+
281
+ const popup: {
282
+ render(w: number): string[];
283
+ invalidate(): void;
284
+ handleInput?(d: string): void;
285
+ dispose?(): void;
286
+ } = {
287
+ render(width: number) {
288
+ const innerWidth = Math.max(20, width - 4);
289
+ const body: string[] = [];
290
+
291
+ if (opts.prompt) {
292
+ body.push(theme.fg("text", opts.prompt));
293
+ body.push("");
294
+ }
295
+
296
+ const editorLines = editor.render(innerWidth);
297
+ body.push(...editorLines);
298
+
299
+ return renderBorderedBox(width, opts.title, body, help, theme, truncateToWidthFn);
300
+ },
301
+ invalidate() {
302
+ editor.invalidate();
303
+ },
304
+ handleInput(data: string) {
305
+ if (matchesKey(data, "escape")) {
306
+ done(undefined);
307
+ return;
308
+ }
309
+ editor.handleInput(data);
310
+ tui.requestRender();
311
+ },
312
+ };
313
+
314
+ return popup;
315
+ },
316
+ { overlay: true },
317
+ );
318
+ }