@herbertgao/pi-extensions 2026.8.13 → 2026.8.14

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 (51) hide show
  1. package/README.md +8 -6
  2. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +12 -0
  3. package/node_modules/@czottmann/pi-automode/README.md +1 -1
  4. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +55 -4
  5. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
  6. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
  8. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  9. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  10. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  11. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  12. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  13. package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
  14. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
  15. package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
  16. package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
  17. package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
  18. package/node_modules/pi-mcp-adapter/CHANGELOG.md +20 -0
  19. package/node_modules/pi-mcp-adapter/README.md +3 -0
  20. package/node_modules/pi-mcp-adapter/cli.js +4 -4
  21. package/node_modules/pi-mcp-adapter/config.ts +1 -0
  22. package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
  23. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  24. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
  25. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
  26. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
  27. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -0
  28. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  29. package/node_modules/pi-mcp-adapter/index.ts +90 -6
  30. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +19 -0
  31. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
  32. package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
  33. package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
  34. package/node_modules/pi-mcp-adapter/package.json +1 -1
  35. package/node_modules/pi-mcp-adapter/proxy-modes.ts +40 -10
  36. package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
  37. package/node_modules/pi-mcp-adapter/types.ts +2 -0
  38. package/node_modules/pi-web-access/CHANGELOG.md +22 -0
  39. package/node_modules/pi-web-access/README.md +11 -8
  40. package/node_modules/pi-web-access/curator-page.ts +12 -3
  41. package/node_modules/pi-web-access/curator-server.ts +3 -1
  42. package/node_modules/pi-web-access/extract.ts +31 -1
  43. package/node_modules/pi-web-access/gemini-search.ts +10 -5
  44. package/node_modules/pi-web-access/index.ts +39 -39
  45. package/node_modules/pi-web-access/package.json +1 -1
  46. package/node_modules/pi-web-access/xcrawl.ts +264 -0
  47. package/package.json +7 -10
  48. package/node_modules/@herbertgao/pi-stash/LICENSE +0 -22
  49. package/node_modules/@herbertgao/pi-stash/README.md +0 -34
  50. package/node_modules/@herbertgao/pi-stash/package.json +0 -51
  51. package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
@@ -0,0 +1,264 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { activityMonitor } from "./activity.ts";
3
+ import type { SearchOptions, SearchResponse } from "./perplexity.ts";
4
+ import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts";
5
+ import { getWebSearchConfigPath } from "./utils.ts";
6
+
7
+ const XCRAWL_API_URL = "https://run.xcrawl.com/v1/serp";
8
+ const CONFIG_PATH = getWebSearchConfigPath();
9
+ // The SERP API is usually fast (a few seconds, cached responses are quicker),
10
+ // but leave generous headroom before treating a slow job as a provider failure.
11
+ const SEARCH_TIMEOUT_MS = 60_000;
12
+
13
+ interface WebSearchConfig {
14
+ xcrawlApiKey?: unknown;
15
+ }
16
+
17
+ interface XCrawlSerpResult {
18
+ title?: unknown;
19
+ link?: unknown;
20
+ snippet?: unknown;
21
+ }
22
+
23
+ interface XCrawlSerpResponse {
24
+ search_metadata?: unknown;
25
+ organic_results?: unknown;
26
+ }
27
+
28
+ let cachedConfig: WebSearchConfig | null = null;
29
+
30
+ function loadConfig(): WebSearchConfig {
31
+ if (cachedConfig) return cachedConfig;
32
+ if (!existsSync(CONFIG_PATH)) {
33
+ cachedConfig = {};
34
+ return cachedConfig;
35
+ }
36
+
37
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
38
+ let parsed: unknown;
39
+ try {
40
+ parsed = JSON.parse(raw);
41
+ } catch (err) {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
44
+ }
45
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
46
+ throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
47
+ }
48
+ cachedConfig = parsed as WebSearchConfig;
49
+ return cachedConfig;
50
+ }
51
+
52
+ async function getApiKey(signal?: AbortSignal): Promise<string | null> {
53
+ return resolveCredential({
54
+ provider: "XCrawl",
55
+ configuredValue: loadConfig().xcrawlApiKey,
56
+ environmentValue: process.env.XCRAWL_API_KEY,
57
+ signal,
58
+ });
59
+ }
60
+
61
+ export function isXcrawlAvailable(): boolean {
62
+ return hasCredentialSource({ provider: "XCrawl", configuredValue: loadConfig().xcrawlApiKey, environmentValue: process.env.XCRAWL_API_KEY });
63
+ }
64
+
65
+ function errorMessage(err: unknown): string {
66
+ return err instanceof Error ? err.message : String(err);
67
+ }
68
+
69
+ function invalidResponse(message: string): Error {
70
+ return new Error(`XCrawl API returned invalid response: ${message}`);
71
+ }
72
+
73
+ function normalizeCount(value: number | undefined): number {
74
+ if (typeof value !== "number" || !Number.isFinite(value)) return 5;
75
+ return Math.max(1, Math.min(Math.floor(value), 20));
76
+ }
77
+
78
+ function hostnameOf(url: string): string {
79
+ try {
80
+ return new URL(url).hostname.toLowerCase();
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
85
+
86
+ // XCrawl's Google SERP results can carry links relative to the API origin
87
+ // (e.g. "/goto?url=..." redirect stubs) instead of the documented absolute
88
+ // URLs. Resolve those against the API origin so callers always receive a
89
+ // well-formed absolute URL; absolute http(s) links pass through unchanged.
90
+ function absolutizeLink(link: string): string {
91
+ if (/^https?:\/\//i.test(link)) return link;
92
+ try {
93
+ return new URL(link, XCRAWL_API_URL).href;
94
+ } catch {
95
+ return link;
96
+ }
97
+ }
98
+
99
+ // Normalize a shared domainFilter entry the same way Valyu does before
100
+ // matching: trim, lowercase, strip URL/paths/ports, validate the shape.
101
+ function normalizeDomain(value: string): string | null {
102
+ let input = value.trim().toLowerCase();
103
+ if (!input) return null;
104
+ if (input.startsWith("-")) input = input.slice(1).trim();
105
+ if (!input) return null;
106
+ try {
107
+ const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
108
+ input = parsed.hostname;
109
+ } catch {
110
+ input = input.split("/")[0]?.split(":")[0] ?? "";
111
+ }
112
+ input = input.replace(/^\.+|\.+$/g, "");
113
+ return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
114
+ }
115
+
116
+ // XCrawl's SERP API has no server-side domain filter, so apply the shared
117
+ // include/exclude domainFilter locally instead of returning off-domain results.
118
+ function applyDomainFilter(results: SearchResponse["results"], domainFilter: NonNullable<SearchOptions["domainFilter"]>): SearchResponse["results"] {
119
+ const includes: string[] = [];
120
+ const excludes: string[] = [];
121
+ for (const raw of domainFilter) {
122
+ const normalized = normalizeDomain(raw);
123
+ if (!normalized) continue;
124
+ if (raw.trim().startsWith("-")) excludes.push(normalized);
125
+ else includes.push(normalized);
126
+ }
127
+ if (!includes.length && !excludes.length) return results;
128
+ return results.filter((result) => {
129
+ const host = hostnameOf(result.url);
130
+ if (!host) return false;
131
+ const matches = (domain: string) => host === domain || host.endsWith(`.${domain}`);
132
+ if (excludes.some(matches)) return false;
133
+ if (includes.length && !includes.some(matches)) return false;
134
+ return true;
135
+ });
136
+ }
137
+
138
+ function parseResponse(value: unknown): SearchResponse["results"] {
139
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
140
+ throw invalidResponse("expected an object envelope");
141
+ }
142
+ const envelope = value as XCrawlSerpResponse;
143
+ const metadata = envelope.search_metadata;
144
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
145
+ throw invalidResponse("expected search_metadata object");
146
+ }
147
+ const status = (metadata as Record<string, unknown>).status;
148
+ if (status !== undefined && status !== "completed") {
149
+ throw invalidResponse(`expected search_metadata.status \"completed\", got ${JSON.stringify(status)}`);
150
+ }
151
+ if (!Array.isArray(envelope.organic_results)) throw invalidResponse("expected organic_results array");
152
+
153
+ const results: SearchResponse["results"] = [];
154
+ for (const [index, value] of (envelope.organic_results as unknown[]).entries()) {
155
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
156
+ throw invalidResponse(`expected organic_results[${index}] object`);
157
+ }
158
+ const result = value as XCrawlSerpResult;
159
+ const { title, link, snippet } = result;
160
+ if (typeof link !== "string" || !link.trim()) throw invalidResponse(`expected organic_results[${index}].link to be a non-empty string`);
161
+ if (title !== null && title !== undefined && typeof title !== "string") {
162
+ throw invalidResponse(`expected organic_results[${index}].title to be a string or null`);
163
+ }
164
+ if (snippet !== undefined && snippet !== null && typeof snippet !== "string") {
165
+ throw invalidResponse(`expected organic_results[${index}].snippet to be a string or null`);
166
+ }
167
+ const resolved = absolutizeLink(link.trim());
168
+ results.push({
169
+ title: typeof title === "string" && title.trim().length > 0 ? title : resolved,
170
+ url: resolved,
171
+ snippet: typeof snippet === "string" ? snippet : "",
172
+ });
173
+ }
174
+
175
+ return results;
176
+ }
177
+
178
+ function buildAnswer(results: SearchResponse["results"]): string {
179
+ return results
180
+ .map((result) => result.snippet
181
+ ? `${result.snippet}\nSource: ${result.title} (${result.url})`
182
+ : `Source: ${result.title} (${result.url})`)
183
+ .join("\n\n");
184
+ }
185
+
186
+ export async function searchWithXCrawl(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
187
+ const apiKey = await getApiKey(options.signal);
188
+ const numResults = normalizeCount(options.numResults);
189
+ if (!apiKey) {
190
+ throw new Error(
191
+ "XCrawl search requires an API key. Set xcrawlApiKey in " + CONFIG_PATH +
192
+ " or export XCRAWL_API_KEY. Get one at https://dash.xcrawl.com/",
193
+ );
194
+ }
195
+ const body = { engine: "google_search", q: query };
196
+ const activityId = activityMonitor.logStart({ type: "api", query });
197
+ // Distinguishing caller cancellation from a provider-side timeout lets the
198
+ // timeout surface as a retriable failure instead of looking like an abort.
199
+ const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
200
+ let response: Response;
201
+
202
+ try {
203
+ response = await fetch(XCRAWL_API_URL, {
204
+ method: "POST",
205
+ headers: {
206
+ Authorization: `Bearer ${apiKey}`,
207
+ "Content-Type": "application/json",
208
+ },
209
+ body: JSON.stringify(body),
210
+ signal: options.signal
211
+ ? AbortSignal.any([timeoutSignal, options.signal])
212
+ : timeoutSignal,
213
+ });
214
+ } catch (err) {
215
+ const message = errorMessage(err);
216
+ if (options.signal?.aborted) {
217
+ activityMonitor.logComplete(activityId, 0);
218
+ throw new Error("Aborted");
219
+ }
220
+ // AbortSignal.timeout rejects with a TimeoutError; treat either that
221
+ // shape or our own fired timer as a retriable provider-side timeout so
222
+ // routing fallback still applies.
223
+ const providerTimeout = timeoutSignal.aborted || (err instanceof Error && err.name === "TimeoutError");
224
+ let outgoing: Error;
225
+ if (providerTimeout) {
226
+ outgoing = new Error(`XCrawl search request timed out after ${Math.round(SEARCH_TIMEOUT_MS / 1000)}s`);
227
+ } else {
228
+ const redactedMessage = redactCredential(message, apiKey);
229
+ outgoing = redactedMessage === message && err instanceof Error ? err : new Error(redactedMessage);
230
+ if (err instanceof Error && outgoing.name === "Error") outgoing.name = err.name;
231
+ }
232
+ activityMonitor.logError(activityId, redactCredential(errorMessage(outgoing), apiKey));
233
+ throw outgoing;
234
+ }
235
+
236
+ if (!response.ok) {
237
+ activityMonitor.logComplete(activityId, response.status);
238
+ const errorText = redactCredential(await response.text(), apiKey);
239
+ let detail = errorText.slice(0, 300);
240
+ try {
241
+ const parsed = JSON.parse(errorText) as { message?: unknown; error?: unknown };
242
+ if (typeof parsed.message === "string") detail = parsed.message.slice(0, 300);
243
+ else if (typeof parsed.error === "string") detail = parsed.error.slice(0, 300);
244
+ } catch {
245
+ // keep raw text slice
246
+ }
247
+ throw new Error(`XCrawl API error (${response.status}): ${detail}`);
248
+ }
249
+ activityMonitor.logComplete(activityId, response.status);
250
+
251
+ let payload: unknown;
252
+ try {
253
+ payload = await response.json();
254
+ } catch (err) {
255
+ throw invalidResponse(`response body is not valid JSON: ${errorMessage(err)}`);
256
+ }
257
+ const results = parseResponse(payload);
258
+ const filtered = (options.domainFilter?.length ? applyDomainFilter(results, options.domainFilter) : results).slice(0, numResults);
259
+
260
+ return {
261
+ answer: buildAnswer(filtered),
262
+ results: filtered,
263
+ };
264
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herbertgao/pi-extensions",
3
- "version": "2026.8.13",
3
+ "version": "2026.8.14",
4
4
  "description": "Aggregate installer for HerbertGao-maintained Pi extensions.",
5
5
  "keywords": [
6
6
  "extensions",
@@ -37,15 +37,14 @@
37
37
  "dependencies": {
38
38
  "@ast-grep/cli": "^0.45.0",
39
39
  "@ast-grep/napi": "^0.45.0",
40
- "@czottmann/pi-automode": "1.13.0",
40
+ "@czottmann/pi-automode": "1.14.0",
41
41
  "@dietrichgebert/ponytail": "4.9.0",
42
42
  "@earendil-works/pi-coding-agent": "^0.84.2",
43
43
  "@earendil-works/pi-tui": "^0.84.2",
44
44
  "@effect/platform-node": "4.0.0-beta.103",
45
45
  "@effect/platform-node-shared": "4.0.0-beta.103",
46
- "@herbertgao/pi-cc-extensions": "0.8.59",
47
- "@herbertgao/pi-stash": "0.1.2",
48
- "@herbertgao/pi-subagents": "0.17.0",
46
+ "@herbertgao/pi-cc-extensions": "0.8.60",
47
+ "@herbertgao/pi-subagents": "0.17.1",
49
48
  "@herbertgao/resume-from": "0.2.0",
50
49
  "@juicesharp/rpiv-ask-user-question": "2.7.1",
51
50
  "@juicesharp/rpiv-config": "^2.7.1",
@@ -66,7 +65,7 @@
66
65
  "@tifan/pi-handoff": "2.0.1",
67
66
  "@tifan/pi-inline-skills": "1.0.5",
68
67
  "@tifan/pi-mermaid-open": "0.2.0",
69
- "@tifan/pi-preferred-thinking": "1.0.0",
68
+ "@tifan/pi-preferred-thinking": "1.0.1",
70
69
  "@tifan/pi-recap": "0.4.5",
71
70
  "@tifan/pi-rename": "0.5.1",
72
71
  "@tifan/pi-titlebar-spinner": "0.1.3",
@@ -90,8 +89,8 @@
90
89
  "p-limit": "^6.1.0",
91
90
  "pi-footer": "0.5.1",
92
91
  "pi-lens": "4.1.2",
93
- "pi-mcp-adapter": "2.29.0",
94
- "pi-web-access": "0.25.0",
92
+ "pi-mcp-adapter": "2.30.0",
93
+ "pi-web-access": "0.26.0",
95
94
  "pidusage": "^4.0.1",
96
95
  "promise.try": "^2.0.1",
97
96
  "qrcode-terminal": "^0.12.0",
@@ -115,7 +114,6 @@
115
114
  "@czottmann/pi-automode",
116
115
  "@dietrichgebert/ponytail",
117
116
  "@herbertgao/pi-cc-extensions",
118
- "@herbertgao/pi-stash",
119
117
  "@herbertgao/pi-subagents",
120
118
  "@herbertgao/resume-from",
121
119
  "@juicesharp/rpiv-ask-user-question",
@@ -152,7 +150,6 @@
152
150
  "./node_modules/@tifan/pi-preferred-thinking/src/index.ts",
153
151
  "./node_modules/@tifan/pi-recap/src/index.ts",
154
152
  "./node_modules/@tifan/pi-rename/src/index.ts",
155
- "./node_modules/@herbertgao/pi-stash/src/index.ts",
156
153
  "./node_modules/@herbertgao/pi-subagents/src/index.ts",
157
154
  "./node_modules/@tifan/pi-titlebar-spinner/src/index.ts",
158
155
  "./node_modules/@juicesharp/rpiv-ask-user-question/index.ts",
@@ -1,22 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Tifan Dwi Avianto
4
- Copyright (c) 2026 Herbert Gao
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in
14
- all copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
- THE SOFTWARE.
@@ -1,34 +0,0 @@
1
- # @herbertgao/pi-stash
2
-
3
- > HerbertGao-maintained fork of [@tifan/pi-stash](https://github.com/tifandotme/pi-extensions/tree/master/packages/pi-stash), distributed under MIT with the original attribution preserved.
4
-
5
- Stash one draft, send another message, and return to the draft while pi works.
6
-
7
- ## Install
8
-
9
- ```bash
10
- pi install npm:@herbertgao/pi-stash
11
- ```
12
-
13
- ## Keybinding
14
-
15
- pi-stash uses `Alt+S`, which does not conflict with Pi's built-in shortcuts.
16
-
17
- ### macOS
18
-
19
- On macOS, press `Option+S` (`Option` is the `Alt` key). If that inserts `ß` instead of triggering pi-stash, configure the terminal to send `Option` as Meta/Escape:
20
-
21
- - Terminal.app: **Settings → Profiles → Keyboard → Use Option as Meta key**.
22
- - iTerm2: **Settings → Profiles → Keys → Left Option key** (or **Right Option key**) → **Esc+**.
23
-
24
- ## Usage
25
-
26
- Press `Alt+S` with text in the editor to stash it. Pi clears the editor so you can send another message, then restores the draft as soon as that message is submitted.
27
-
28
- Press `Alt+S` again while the editor is empty to restore the draft manually. If the editor contains new text, pi-stash keeps both drafts unchanged and refuses to overwrite either one.
29
-
30
- The stash belongs to the current session and survives reloads and restarts. Slash commands and `!` shell commands do not consume it.
31
-
32
- ## License
33
-
34
- [MIT](LICENSE)
@@ -1,51 +0,0 @@
1
- {
2
- "name": "@herbertgao/pi-stash",
3
- "version": "0.1.2",
4
- "description": "Stash one pi draft and restore it after the next message.",
5
- "keywords": [
6
- "pi",
7
- "pi-extension",
8
- "pi-package",
9
- "prompt",
10
- "stash"
11
- ],
12
- "homepage": "https://github.com/HerbertGao/pi-extensions/tree/master/packages/pi-stash#readme",
13
- "bugs": "https://github.com/HerbertGao/pi-extensions/issues",
14
- "license": "MIT",
15
- "author": "Herbert Gao",
16
- "contributors": [
17
- "Tifan Dwi Avianto <hi@tifan.me>"
18
- ],
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/HerbertGao/pi-extensions.git",
22
- "directory": "packages/pi-stash"
23
- },
24
- "files": [
25
- "src/**/*.ts",
26
- "README.md",
27
- "LICENSE"
28
- ],
29
- "type": "module",
30
- "publishConfig": {
31
- "access": "public",
32
- "provenance": true
33
- },
34
- "scripts": {
35
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
36
- },
37
- "engines": {
38
- "node": ">=20"
39
- },
40
- "pi": {
41
- "extensions": [
42
- "./src/index.ts"
43
- ]
44
- },
45
- "x-upstream": {
46
- "package": "@tifan/pi-stash",
47
- "version": "0.2.0",
48
- "repository": "https://github.com/tifandotme/pi-extensions",
49
- "commit": "b39d1e6359c78718cbf8fe5c74c82b89065e9a0b"
50
- }
51
- }
@@ -1,118 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- InputSource,
5
- SessionEntry,
6
- } from "@earendil-works/pi-coding-agent"
7
-
8
- export const STASH_ENTRY_TYPE = "pi-stash-state"
9
- export const STASH_SHORTCUT = "alt+s"
10
-
11
- interface StashState {
12
- draft: string | null
13
- }
14
-
15
- export type ShortcutAction =
16
- | { type: "none" }
17
- | { type: "reject" }
18
- | { type: "restore"; draft: string }
19
- | { type: "stash"; draft: string }
20
-
21
- export function getShortcutAction(
22
- editorText: string,
23
- pendingDraft: string | null,
24
- ): ShortcutAction {
25
- if (pendingDraft !== null) {
26
- return editorText.trim()
27
- ? { type: "reject" }
28
- : { type: "restore", draft: pendingDraft }
29
- }
30
-
31
- return editorText.trim()
32
- ? { type: "stash", draft: editorText }
33
- : { type: "none" }
34
- }
35
-
36
- export function shouldAutoRestore(source: InputSource, text: string) {
37
- return source === "interactive" && !text.trimStart().startsWith("/")
38
- }
39
-
40
- export function getPendingDraft(
41
- entries: readonly SessionEntry[],
42
- ): string | null {
43
- let draft: string | null = null
44
-
45
- for (const entry of entries) {
46
- if (entry.type !== "custom" || entry.customType !== STASH_ENTRY_TYPE) {
47
- continue
48
- }
49
-
50
- const data = entry.data
51
- if (
52
- typeof data === "object" &&
53
- data !== null &&
54
- "draft" in data &&
55
- (typeof data.draft === "string" || data.draft === null)
56
- ) {
57
- draft = data.draft
58
- }
59
- }
60
-
61
- return draft
62
- }
63
-
64
- export default function stashExtension(pi: ExtensionAPI) {
65
- let pendingDraft: string | null = null
66
-
67
- function saveDraft(draft: string | null) {
68
- pendingDraft = draft
69
- pi.appendEntry<StashState>(STASH_ENTRY_TYPE, { draft })
70
- }
71
-
72
- function restoreDraft(ctx: ExtensionContext) {
73
- if (pendingDraft === null) return
74
-
75
- const draft = pendingDraft
76
- ctx.ui.setEditorText(draft)
77
- saveDraft(null)
78
- ctx.ui.notify("Draft restored", "info")
79
- }
80
-
81
- pi.on("session_start", (_event, ctx) => {
82
- pendingDraft = getPendingDraft(ctx.sessionManager.getBranch())
83
- if (
84
- ctx.mode === "tui" &&
85
- pendingDraft !== null &&
86
- !ctx.ui.getEditorText().trim()
87
- ) {
88
- restoreDraft(ctx)
89
- }
90
- })
91
-
92
- pi.on("input", (event, ctx) => {
93
- if (shouldAutoRestore(event.source, event.text)) restoreDraft(ctx)
94
- })
95
-
96
- pi.registerShortcut(STASH_SHORTCUT, {
97
- description: "Stash or restore the current draft",
98
- handler: (ctx) => {
99
- const action = getShortcutAction(ctx.ui.getEditorText(), pendingDraft)
100
-
101
- switch (action.type) {
102
- case "stash":
103
- saveDraft(action.draft)
104
- ctx.ui.setEditorText("")
105
- ctx.ui.notify("Draft stashed", "info")
106
- break
107
- case "restore":
108
- restoreDraft(ctx)
109
- break
110
- case "reject":
111
- ctx.ui.notify("A draft is already stashed", "warning")
112
- break
113
- case "none":
114
- break
115
- }
116
- },
117
- })
118
- }