@mariozechner/pi-coding-agent 0.49.1 → 0.49.3

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 (100) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +5 -11
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +1 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +6 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-session.d.ts.map +1 -1
  11. package/dist/core/agent-session.js +6 -0
  12. package/dist/core/agent-session.js.map +1 -1
  13. package/dist/core/export-html/template.css +19 -0
  14. package/dist/core/export-html/template.js +70 -5
  15. package/dist/core/extensions/index.d.ts +1 -1
  16. package/dist/core/extensions/index.d.ts.map +1 -1
  17. package/dist/core/extensions/index.js.map +1 -1
  18. package/dist/core/extensions/types.d.ts +10 -3
  19. package/dist/core/extensions/types.d.ts.map +1 -1
  20. package/dist/core/extensions/types.js.map +1 -1
  21. package/dist/core/model-registry.d.ts.map +1 -1
  22. package/dist/core/model-registry.js +1 -3
  23. package/dist/core/model-registry.js.map +1 -1
  24. package/dist/core/sdk.d.ts.map +1 -1
  25. package/dist/core/sdk.js +11 -3
  26. package/dist/core/sdk.js.map +1 -1
  27. package/dist/core/settings-manager.d.ts +5 -0
  28. package/dist/core/settings-manager.d.ts.map +1 -1
  29. package/dist/core/settings-manager.js +3 -0
  30. package/dist/core/settings-manager.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/main.d.ts.map +1 -1
  35. package/dist/main.js +2 -1
  36. package/dist/main.js.map +1 -1
  37. package/dist/modes/interactive/components/assistant-message.d.ts +3 -2
  38. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  39. package/dist/modes/interactive/components/assistant-message.js +5 -3
  40. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  41. package/dist/modes/interactive/components/branch-summary-message.d.ts +3 -2
  42. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
  43. package/dist/modes/interactive/components/branch-summary-message.js +4 -2
  44. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
  45. package/dist/modes/interactive/components/compaction-summary-message.d.ts +3 -2
  46. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
  47. package/dist/modes/interactive/components/compaction-summary-message.js +4 -2
  48. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
  49. package/dist/modes/interactive/components/custom-message.d.ts +3 -2
  50. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
  51. package/dist/modes/interactive/components/custom-message.js +4 -2
  52. package/dist/modes/interactive/components/custom-message.js.map +1 -1
  53. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  54. package/dist/modes/interactive/components/footer.js +5 -0
  55. package/dist/modes/interactive/components/footer.js.map +1 -1
  56. package/dist/modes/interactive/components/model-selector.d.ts +9 -0
  57. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  58. package/dist/modes/interactive/components/model-selector.js +84 -38
  59. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  60. package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  61. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  62. package/dist/modes/interactive/components/settings-selector.js +10 -0
  63. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  64. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  65. package/dist/modes/interactive/components/tool-execution.js +7 -0
  66. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  67. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  68. package/dist/modes/interactive/components/tree-selector.js +2 -2
  69. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  70. package/dist/modes/interactive/components/user-message.d.ts +2 -2
  71. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  72. package/dist/modes/interactive/components/user-message.js +2 -2
  73. package/dist/modes/interactive/components/user-message.js.map +1 -1
  74. package/dist/modes/interactive/interactive-mode.d.ts +10 -2
  75. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  76. package/dist/modes/interactive/interactive-mode.js +81 -38
  77. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  78. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  79. package/dist/modes/interactive/theme/theme.js +7 -3
  80. package/dist/modes/interactive/theme/theme.js.map +1 -1
  81. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  82. package/dist/modes/rpc/rpc-mode.js +2 -1
  83. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  84. package/dist/modes/rpc/rpc-types.d.ts +1 -0
  85. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  86. package/dist/modes/rpc/rpc-types.js.map +1 -1
  87. package/dist/utils/shell.d.ts.map +1 -1
  88. package/dist/utils/shell.js +3 -2
  89. package/dist/utils/shell.js.map +1 -1
  90. package/docs/extensions.md +5 -3
  91. package/docs/tui.md +6 -3
  92. package/examples/extensions/README.md +3 -0
  93. package/examples/extensions/antigravity-image-gen.ts +413 -0
  94. package/examples/extensions/inline-bash.ts +94 -0
  95. package/examples/extensions/question.ts +9 -22
  96. package/examples/extensions/space-invaders.ts +560 -0
  97. package/examples/extensions/widget-placement.ts +17 -0
  98. package/examples/extensions/with-deps/package-lock.json +2 -2
  99. package/examples/extensions/with-deps/package.json +1 -1
  100. package/package.json +4 -4
@@ -184,7 +184,7 @@ export default function (pi: ExtensionAPI) {
184
184
  const ok = await ctx.ui.confirm("Title", "Are you sure?");
185
185
  ctx.ui.notify("Done!", "success");
186
186
  ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
187
- ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor
187
+ ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
188
188
  });
189
189
 
190
190
  // Register tools, commands, shortcuts, flags
@@ -1366,7 +1366,7 @@ Extensions can interact with users via `ctx.ui` methods and customize how messag
1366
1366
  - Settings toggles (SettingsList)
1367
1367
  - Status indicators (setStatus)
1368
1368
  - Working message during streaming (setWorkingMessage)
1369
- - Widgets above editor (setWidget)
1369
+ - Widgets above/below editor (setWidget)
1370
1370
  - Custom footers (setFooter)
1371
1371
 
1372
1372
  ### Dialogs
@@ -1456,8 +1456,10 @@ ctx.ui.setStatus("my-ext", undefined); // Clear
1456
1456
  ctx.ui.setWorkingMessage("Thinking deeply...");
1457
1457
  ctx.ui.setWorkingMessage(); // Restore default
1458
1458
 
1459
- // Widget above editor (string array or factory function)
1459
+ // Widget above editor (default)
1460
1460
  ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
1461
+ // Widget below editor
1462
+ ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
1461
1463
  ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
1462
1464
  ctx.ui.setWidget("my-widget", undefined); // Clear
1463
1465
 
package/docs/tui.md CHANGED
@@ -704,14 +704,17 @@ ctx.ui.setStatus("my-ext", undefined);
704
704
 
705
705
  **Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
706
706
 
707
- ### Pattern 5: Widget Above Editor
707
+ ### Pattern 5: Widgets Above/Below Editor
708
708
 
709
- Show persistent content above the input editor. Good for todo lists, progress.
709
+ Show persistent content above or below the input editor. Good for todo lists, progress.
710
710
 
711
711
  ```typescript
712
- // Simple string array
712
+ // Simple string array (above editor by default)
713
713
  ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
714
714
 
715
+ // Render below the editor
716
+ ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
717
+
715
718
  // Or with theme
716
719
  ctx.ui.setWidget("my-widget", (_tui, theme) => {
717
720
  const lines = items.map((item, i) =>
@@ -34,6 +34,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
34
34
  | `questionnaire.ts` | Multi-question input with tab bar navigation between questions |
35
35
  | `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
36
36
  | `truncated-tool.ts` | Wraps ripgrep with proper output truncation (50KB/2000 lines) |
37
+ | `antigravity-image-gen.ts` | Generate images via Google Antigravity with optional save-to-disk modes |
37
38
  | `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations |
38
39
  | `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
39
40
 
@@ -47,6 +48,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
47
48
  | `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
48
49
  | `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
49
50
  | `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
51
+ | `widget-placement.ts` | Shows widgets above and below the editor via `ctx.ui.setWidget()` placement |
50
52
  | `model-status.ts` | Shows model changes in status bar via `model_select` hook |
51
53
  | `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
52
54
  | `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
@@ -62,6 +64,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
62
64
  | `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
63
65
  | `shutdown-command.ts` | Adds `/quit` command demonstrating `ctx.shutdown()` |
64
66
  | `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook |
67
+ | `inline-bash.ts` | Expands `!{command}` patterns in prompts via `input` event transformation |
65
68
 
66
69
  ### Git Integration
67
70
 
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Antigravity Image Generation
3
+ *
4
+ * Generates images via Google Antigravity's image models (gemini-3-pro-image, imagen-3).
5
+ * Returns images as tool result attachments for inline terminal rendering.
6
+ * Requires OAuth login via /login for google-antigravity.
7
+ *
8
+ * Usage:
9
+ * "Generate an image of a sunset over mountains"
10
+ * "Create a 16:9 wallpaper of a cyberpunk city"
11
+ *
12
+ * Save modes (tool param, env var, or config file):
13
+ * save=none - Don't save to disk (default)
14
+ * save=project - Save to <repo>/.pi/generated-images/
15
+ * save=global - Save to ~/.pi/agent/generated-images/
16
+ * save=custom - Save to saveDir param or PI_IMAGE_SAVE_DIR
17
+ *
18
+ * Environment variables:
19
+ * PI_IMAGE_SAVE_MODE - Default save mode (none|project|global|custom)
20
+ * PI_IMAGE_SAVE_DIR - Directory for custom save mode
21
+ *
22
+ * Config files (project overrides global):
23
+ * ~/.pi/agent/extensions/antigravity-image-gen.json
24
+ * <repo>/.pi/extensions/antigravity-image-gen.json
25
+ * Example: { "save": "global" }
26
+ */
27
+
28
+ import { randomUUID } from "node:crypto";
29
+ import { existsSync, readFileSync } from "node:fs";
30
+ import { mkdir, writeFile } from "node:fs/promises";
31
+ import { homedir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { StringEnum } from "@mariozechner/pi-ai";
34
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
35
+ import { type Static, Type } from "@sinclair/typebox";
36
+
37
+ const PROVIDER = "google-antigravity";
38
+
39
+ const ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"] as const;
40
+
41
+ type AspectRatio = (typeof ASPECT_RATIOS)[number];
42
+
43
+ const DEFAULT_MODEL = "gemini-3-pro-image";
44
+ const DEFAULT_ASPECT_RATIO: AspectRatio = "1:1";
45
+ const DEFAULT_SAVE_MODE = "none";
46
+
47
+ const SAVE_MODES = ["none", "project", "global", "custom"] as const;
48
+ type SaveMode = (typeof SAVE_MODES)[number];
49
+
50
+ const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
51
+
52
+ const ANTIGRAVITY_HEADERS = {
53
+ "User-Agent": "antigravity/1.11.5 darwin/arm64",
54
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
55
+ "Client-Metadata": JSON.stringify({
56
+ ideType: "IDE_UNSPECIFIED",
57
+ platform: "PLATFORM_UNSPECIFIED",
58
+ pluginType: "GEMINI",
59
+ }),
60
+ };
61
+
62
+ const IMAGE_SYSTEM_INSTRUCTION =
63
+ "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
64
+
65
+ const TOOL_PARAMS = Type.Object({
66
+ prompt: Type.String({ description: "Image description." }),
67
+ model: Type.Optional(
68
+ Type.String({
69
+ description: "Image model id (e.g., gemini-3-pro-image, imagen-3). Default: gemini-3-pro-image.",
70
+ }),
71
+ ),
72
+ aspectRatio: Type.Optional(StringEnum(ASPECT_RATIOS)),
73
+ save: Type.Optional(StringEnum(SAVE_MODES)),
74
+ saveDir: Type.Optional(
75
+ Type.String({
76
+ description: "Directory to save image when save=custom. Defaults to PI_IMAGE_SAVE_DIR if set.",
77
+ }),
78
+ ),
79
+ });
80
+
81
+ type ToolParams = Static<typeof TOOL_PARAMS>;
82
+
83
+ interface CloudCodeAssistRequest {
84
+ project: string;
85
+ model: string;
86
+ request: {
87
+ contents: Content[];
88
+ sessionId?: string;
89
+ systemInstruction?: { role?: string; parts: { text: string }[] };
90
+ generationConfig?: {
91
+ maxOutputTokens?: number;
92
+ temperature?: number;
93
+ imageConfig?: { aspectRatio?: string };
94
+ candidateCount?: number;
95
+ };
96
+ safetySettings?: Array<{ category: string; threshold: string }>;
97
+ };
98
+ requestType?: string;
99
+ userAgent?: string;
100
+ requestId?: string;
101
+ }
102
+
103
+ interface CloudCodeAssistResponseChunk {
104
+ response?: {
105
+ candidates?: Array<{
106
+ content?: {
107
+ role: string;
108
+ parts?: Array<{
109
+ text?: string;
110
+ inlineData?: {
111
+ mimeType?: string;
112
+ data?: string;
113
+ };
114
+ }>;
115
+ };
116
+ }>;
117
+ usageMetadata?: {
118
+ promptTokenCount?: number;
119
+ candidatesTokenCount?: number;
120
+ thoughtsTokenCount?: number;
121
+ totalTokenCount?: number;
122
+ cachedContentTokenCount?: number;
123
+ };
124
+ modelVersion?: string;
125
+ responseId?: string;
126
+ };
127
+ traceId?: string;
128
+ }
129
+
130
+ interface Content {
131
+ role: "user" | "model";
132
+ parts: Part[];
133
+ }
134
+
135
+ interface Part {
136
+ text?: string;
137
+ inlineData?: {
138
+ mimeType?: string;
139
+ data?: string;
140
+ };
141
+ }
142
+
143
+ interface ParsedCredentials {
144
+ accessToken: string;
145
+ projectId: string;
146
+ }
147
+
148
+ interface ExtensionConfig {
149
+ save?: SaveMode;
150
+ saveDir?: string;
151
+ }
152
+
153
+ interface SaveConfig {
154
+ mode: SaveMode;
155
+ outputDir?: string;
156
+ }
157
+
158
+ function parseOAuthCredentials(raw: string): ParsedCredentials {
159
+ let parsed: { token?: string; projectId?: string };
160
+ try {
161
+ parsed = JSON.parse(raw) as { token?: string; projectId?: string };
162
+ } catch {
163
+ throw new Error("Invalid Google OAuth credentials. Run /login to re-authenticate.");
164
+ }
165
+ if (!parsed.token || !parsed.projectId) {
166
+ throw new Error("Missing token or projectId in Google OAuth credentials. Run /login.");
167
+ }
168
+ return { accessToken: parsed.token, projectId: parsed.projectId };
169
+ }
170
+
171
+ function readConfigFile(path: string): ExtensionConfig {
172
+ if (!existsSync(path)) {
173
+ return {};
174
+ }
175
+ try {
176
+ const content = readFileSync(path, "utf-8");
177
+ const parsed = JSON.parse(content) as ExtensionConfig;
178
+ return parsed ?? {};
179
+ } catch {
180
+ return {};
181
+ }
182
+ }
183
+
184
+ function loadConfig(cwd: string): ExtensionConfig {
185
+ const globalConfig = readConfigFile(join(homedir(), ".pi", "agent", "extensions", "antigravity-image-gen.json"));
186
+ const projectConfig = readConfigFile(join(cwd, ".pi", "extensions", "antigravity-image-gen.json"));
187
+ return { ...globalConfig, ...projectConfig };
188
+ }
189
+
190
+ function resolveSaveConfig(params: ToolParams, cwd: string): SaveConfig {
191
+ const config = loadConfig(cwd);
192
+ const envMode = (process.env.PI_IMAGE_SAVE_MODE || "").toLowerCase();
193
+ const paramMode = params.save;
194
+ const mode = (paramMode || envMode || config.save || DEFAULT_SAVE_MODE) as SaveMode;
195
+
196
+ if (!SAVE_MODES.includes(mode)) {
197
+ return { mode: DEFAULT_SAVE_MODE as SaveMode };
198
+ }
199
+
200
+ if (mode === "project") {
201
+ return { mode, outputDir: join(cwd, ".pi", "generated-images") };
202
+ }
203
+
204
+ if (mode === "global") {
205
+ return { mode, outputDir: join(homedir(), ".pi", "agent", "generated-images") };
206
+ }
207
+
208
+ if (mode === "custom") {
209
+ const dir = params.saveDir || process.env.PI_IMAGE_SAVE_DIR || config.saveDir;
210
+ if (!dir || !dir.trim()) {
211
+ throw new Error("save=custom requires saveDir or PI_IMAGE_SAVE_DIR.");
212
+ }
213
+ return { mode, outputDir: dir };
214
+ }
215
+
216
+ return { mode };
217
+ }
218
+
219
+ function imageExtension(mimeType: string): string {
220
+ const lower = mimeType.toLowerCase();
221
+ if (lower.includes("jpeg") || lower.includes("jpg")) return "jpg";
222
+ if (lower.includes("gif")) return "gif";
223
+ if (lower.includes("webp")) return "webp";
224
+ return "png";
225
+ }
226
+
227
+ async function saveImage(base64Data: string, mimeType: string, outputDir: string): Promise<string> {
228
+ await mkdir(outputDir, { recursive: true });
229
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
230
+ const ext = imageExtension(mimeType);
231
+ const filename = `image-${timestamp}-${randomUUID().slice(0, 8)}.${ext}`;
232
+ const filePath = join(outputDir, filename);
233
+ await writeFile(filePath, Buffer.from(base64Data, "base64"));
234
+ return filePath;
235
+ }
236
+
237
+ function buildRequest(prompt: string, model: string, projectId: string, aspectRatio: string): CloudCodeAssistRequest {
238
+ return {
239
+ project: projectId,
240
+ model,
241
+ request: {
242
+ contents: [
243
+ {
244
+ role: "user",
245
+ parts: [{ text: prompt }],
246
+ },
247
+ ],
248
+ systemInstruction: {
249
+ parts: [{ text: IMAGE_SYSTEM_INSTRUCTION }],
250
+ },
251
+ generationConfig: {
252
+ imageConfig: { aspectRatio },
253
+ candidateCount: 1,
254
+ },
255
+ safetySettings: [
256
+ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_ONLY_HIGH" },
257
+ { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
258
+ { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_ONLY_HIGH" },
259
+ { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" },
260
+ { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "BLOCK_ONLY_HIGH" },
261
+ ],
262
+ },
263
+ requestType: "agent",
264
+ requestId: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
265
+ userAgent: "antigravity",
266
+ };
267
+ }
268
+
269
+ async function parseSseForImage(
270
+ response: Response,
271
+ signal?: AbortSignal,
272
+ ): Promise<{ image: { data: string; mimeType: string }; text: string[] }> {
273
+ if (!response.body) {
274
+ throw new Error("No response body");
275
+ }
276
+
277
+ const reader = response.body.getReader();
278
+ const decoder = new TextDecoder();
279
+ let buffer = "";
280
+ const textParts: string[] = [];
281
+
282
+ try {
283
+ while (true) {
284
+ if (signal?.aborted) {
285
+ throw new Error("Request was aborted");
286
+ }
287
+
288
+ const { done, value } = await reader.read();
289
+ if (done) break;
290
+
291
+ buffer += decoder.decode(value, { stream: true });
292
+ const lines = buffer.split("\n");
293
+ buffer = lines.pop() || "";
294
+
295
+ for (const line of lines) {
296
+ if (!line.startsWith("data:")) continue;
297
+ const jsonStr = line.slice(5).trim();
298
+ if (!jsonStr) continue;
299
+
300
+ let chunk: CloudCodeAssistResponseChunk;
301
+ try {
302
+ chunk = JSON.parse(jsonStr) as CloudCodeAssistResponseChunk;
303
+ } catch {
304
+ continue;
305
+ }
306
+
307
+ const responseData = chunk.response;
308
+ if (!responseData?.candidates) continue;
309
+
310
+ for (const candidate of responseData.candidates) {
311
+ const parts = candidate.content?.parts;
312
+ if (!parts) continue;
313
+ for (const part of parts) {
314
+ if (part.text) {
315
+ textParts.push(part.text);
316
+ }
317
+ if (part.inlineData?.data) {
318
+ await reader.cancel();
319
+ return {
320
+ image: {
321
+ data: part.inlineData.data,
322
+ mimeType: part.inlineData.mimeType || "image/png",
323
+ },
324
+ text: textParts,
325
+ };
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
331
+ } finally {
332
+ reader.releaseLock();
333
+ }
334
+
335
+ throw new Error("No image data returned by the model");
336
+ }
337
+
338
+ async function getCredentials(ctx: {
339
+ modelRegistry: { getApiKeyForProvider: (provider: string) => Promise<string | undefined> };
340
+ }): Promise<ParsedCredentials> {
341
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
342
+ if (!apiKey) {
343
+ throw new Error("Missing Google Antigravity OAuth credentials. Run /login for google-antigravity.");
344
+ }
345
+ return parseOAuthCredentials(apiKey);
346
+ }
347
+
348
+ export default function antigravityImageGen(pi: ExtensionAPI) {
349
+ pi.registerTool({
350
+ name: "generate_image",
351
+ label: "Generate image",
352
+ description:
353
+ "Generate an image via Google Antigravity image models. Returns the image as a tool result attachment. Optional saving via save=project|global|custom|none, or PI_IMAGE_SAVE_MODE/PI_IMAGE_SAVE_DIR.",
354
+ parameters: TOOL_PARAMS,
355
+ async execute(_toolCallId, params: ToolParams, onUpdate, ctx, signal) {
356
+ const { accessToken, projectId } = await getCredentials(ctx);
357
+ const model = params.model || DEFAULT_MODEL;
358
+ const aspectRatio = params.aspectRatio || DEFAULT_ASPECT_RATIO;
359
+
360
+ const requestBody = buildRequest(params.prompt, model, projectId, aspectRatio);
361
+ onUpdate?.({
362
+ content: [{ type: "text", text: `Requesting image from ${PROVIDER}/${model}...` }],
363
+ details: { provider: PROVIDER, model, aspectRatio },
364
+ });
365
+
366
+ const response = await fetch(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
367
+ method: "POST",
368
+ headers: {
369
+ Authorization: `Bearer ${accessToken}`,
370
+ "Content-Type": "application/json",
371
+ Accept: "text/event-stream",
372
+ ...ANTIGRAVITY_HEADERS,
373
+ },
374
+ body: JSON.stringify(requestBody),
375
+ signal,
376
+ });
377
+
378
+ if (!response.ok) {
379
+ const errorText = await response.text();
380
+ throw new Error(`Image request failed (${response.status}): ${errorText}`);
381
+ }
382
+
383
+ const parsed = await parseSseForImage(response, signal);
384
+ const saveConfig = resolveSaveConfig(params, ctx.cwd);
385
+ let savedPath: string | undefined;
386
+ let saveError: string | undefined;
387
+ if (saveConfig.mode !== "none" && saveConfig.outputDir) {
388
+ try {
389
+ savedPath = await saveImage(parsed.image.data, parsed.image.mimeType, saveConfig.outputDir);
390
+ } catch (error) {
391
+ saveError = error instanceof Error ? error.message : String(error);
392
+ }
393
+ }
394
+ const summaryParts = [`Generated image via ${PROVIDER}/${model}.`, `Aspect ratio: ${aspectRatio}.`];
395
+ if (savedPath) {
396
+ summaryParts.push(`Saved image to: ${savedPath}`);
397
+ } else if (saveError) {
398
+ summaryParts.push(`Failed to save image: ${saveError}`);
399
+ }
400
+ if (parsed.text.length > 0) {
401
+ summaryParts.push(`Model notes: ${parsed.text.join(" ")}`);
402
+ }
403
+
404
+ return {
405
+ content: [
406
+ { type: "text", text: summaryParts.join(" ") },
407
+ { type: "image", data: parsed.image.data, mimeType: parsed.image.mimeType },
408
+ ],
409
+ details: { provider: PROVIDER, model, aspectRatio, savedPath, saveMode: saveConfig.mode },
410
+ };
411
+ },
412
+ });
413
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Inline Bash Extension - expands inline bash commands in user prompts.
3
+ *
4
+ * Start pi with this extension:
5
+ * pi -e ./examples/extensions/inline-bash.ts
6
+ *
7
+ * Then type prompts with inline bash:
8
+ * What's in !{pwd}?
9
+ * The current branch is !{git branch --show-current} and status: !{git status --short}
10
+ * My node version is !{node --version}
11
+ *
12
+ * The !{command} patterns are executed and replaced with their output before
13
+ * the prompt is sent to the agent.
14
+ *
15
+ * Note: Regular !command syntax (whole-line bash) is preserved and works as before.
16
+ */
17
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
18
+
19
+ export default function (pi: ExtensionAPI) {
20
+ const PATTERN = /!\{([^}]+)\}/g;
21
+ const TIMEOUT_MS = 30000;
22
+
23
+ pi.on("input", async (event, ctx) => {
24
+ const text = event.text;
25
+
26
+ // Don't process if it's a whole-line bash command (starts with !)
27
+ // This preserves the existing !command behavior
28
+ if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
29
+ return { action: "continue" };
30
+ }
31
+
32
+ // Check if there are any inline bash patterns
33
+ if (!PATTERN.test(text)) {
34
+ return { action: "continue" };
35
+ }
36
+
37
+ // Reset regex state after test()
38
+ PATTERN.lastIndex = 0;
39
+
40
+ let result = text;
41
+ const expansions: Array<{ command: string; output: string; error?: string }> = [];
42
+
43
+ // Find all matches first (to avoid issues with replacing while iterating)
44
+ const matches: Array<{ full: string; command: string }> = [];
45
+ let match = PATTERN.exec(text);
46
+ while (match) {
47
+ matches.push({ full: match[0], command: match[1] });
48
+ match = PATTERN.exec(text);
49
+ }
50
+
51
+ // Execute each command and collect results
52
+ for (const { full, command } of matches) {
53
+ try {
54
+ const bashResult = await pi.exec("bash", ["-c", command], {
55
+ timeout: TIMEOUT_MS,
56
+ });
57
+
58
+ const output = bashResult.stdout || bashResult.stderr || "";
59
+ const trimmed = output.trim();
60
+
61
+ if (bashResult.code !== 0 && bashResult.stderr) {
62
+ expansions.push({
63
+ command,
64
+ output: trimmed,
65
+ error: `exit code ${bashResult.code}`,
66
+ });
67
+ } else {
68
+ expansions.push({ command, output: trimmed });
69
+ }
70
+
71
+ result = result.replace(full, trimmed);
72
+ } catch (err) {
73
+ const errorMsg = err instanceof Error ? err.message : String(err);
74
+ expansions.push({ command, output: "", error: errorMsg });
75
+ result = result.replace(full, `[error: ${errorMsg}]`);
76
+ }
77
+ }
78
+
79
+ // Show what was expanded (if UI available)
80
+ if (ctx.hasUI && expansions.length > 0) {
81
+ const summary = expansions
82
+ .map((e) => {
83
+ const status = e.error ? ` (${e.error})` : "";
84
+ const preview = e.output.length > 50 ? `${e.output.slice(0, 50)}...` : e.output;
85
+ return `!{${e.command}}${status} -> "${preview}"`;
86
+ })
87
+ .join("\n");
88
+
89
+ ctx.ui.notify(`Expanded ${expansions.length} inline command(s):\n${summary}`, "info");
90
+ }
91
+
92
+ return { action: "transform", text: result, images: event.images };
93
+ });
94
+ }
@@ -22,28 +22,17 @@ interface QuestionDetails {
22
22
  wasCustom?: boolean;
23
23
  }
24
24
 
25
- // Support both simple strings and objects with descriptions
26
- const OptionSchema = Type.Union([
27
- Type.String(),
28
- Type.Object({
29
- label: Type.String({ description: "Display label for the option" }),
30
- description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
31
- }),
32
- ]);
25
+ // Options with labels and optional descriptions
26
+ const OptionSchema = Type.Object({
27
+ label: Type.String({ description: "Display label for the option" }),
28
+ description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
29
+ });
33
30
 
34
31
  const QuestionParams = Type.Object({
35
32
  question: Type.String({ description: "The question to ask the user" }),
36
33
  options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
37
34
  });
38
35
 
39
- // Normalize option to { label, description? }
40
- function normalizeOption(opt: string | { label: string; description?: string }): OptionWithDesc {
41
- if (typeof opt === "string") {
42
- return { label: opt };
43
- }
44
- return opt;
45
- }
46
-
47
36
  export default function question(pi: ExtensionAPI) {
48
37
  pi.registerTool({
49
38
  name: "question",
@@ -57,7 +46,7 @@ export default function question(pi: ExtensionAPI) {
57
46
  content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
58
47
  details: {
59
48
  question: params.question,
60
- options: params.options.map((o) => (typeof o === "string" ? o : o.label)),
49
+ options: params.options.map((o) => o.label),
61
50
  answer: null,
62
51
  } as QuestionDetails,
63
52
  };
@@ -70,9 +59,7 @@ export default function question(pi: ExtensionAPI) {
70
59
  };
71
60
  }
72
61
 
73
- // Normalize options
74
- const normalizedOptions = params.options.map(normalizeOption);
75
- const allOptions: DisplayOption[] = [...normalizedOptions, { label: "Type something.", isOther: true }];
62
+ const allOptions: DisplayOption[] = [...params.options, { label: "Type something.", isOther: true }];
76
63
 
77
64
  const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
78
65
  (tui, theme, _kb, done) => {
@@ -209,7 +196,7 @@ export default function question(pi: ExtensionAPI) {
209
196
  );
210
197
 
211
198
  // Build simple options list for details
212
- const simpleOptions = normalizedOptions.map((o) => o.label);
199
+ const simpleOptions = params.options.map((o) => o.label);
213
200
 
214
201
  if (!result) {
215
202
  return {
@@ -244,7 +231,7 @@ export default function question(pi: ExtensionAPI) {
244
231
  let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
245
232
  const opts = Array.isArray(args.options) ? args.options : [];
246
233
  if (opts.length) {
247
- const labels = opts.map((o: string | { label: string }) => (typeof o === "string" ? o : o.label));
234
+ const labels = opts.map((o: OptionWithDesc) => o.label);
248
235
  const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`);
249
236
  text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
250
237
  }