@agnishc/edb-gemini-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Initial release: `gemini_proxy` tool with stream-json event parsing
7
+ - `approvalMode` parameter: yolo / auto_edit / plan
8
+ - File context injection and `includeDirectories` workspace expansion
9
+ - Collapsed and expanded TUI rendering with per-tool-call status icons
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agnish Chakraborty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @agnishc/edb-gemini-proxy
2
+
3
+ A Pi CLI extension that registers a `gemini_proxy` tool — lets the pi agent delegate tasks to **Google's Gemini CLI** running in headless mode.
4
+
5
+ ## Use cases
6
+
7
+ - Cross-model second opinion (different perspective from Gemini vs the primary agent)
8
+ - Tasks that benefit from Gemini's large context window
9
+ - Google Search grounding
10
+ - Code review, security audit, diff analysis
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pi install npm:@agnishc/edb-gemini-proxy
16
+ ```
17
+
18
+ ## Requirements
19
+
20
+ - `gemini` CLI on PATH (`npm install -g @google/gemini-cli`)
21
+ - Auth: `GEMINI_API_KEY` env var, or run `gemini auth` for OAuth
22
+ - Set `GEMINI_PATH` env var to override the binary location
23
+
24
+ ## Parameters
25
+
26
+ | Parameter | Type | Description |
27
+ |-----------|------|-------------|
28
+ | `prompt` | string | The task for Gemini — be specific |
29
+ | `systemPrompt` | string? | Role / instructions prepended to context |
30
+ | `model` | string? | e.g. `gemini-2.5-pro`, `gemini-2.5-flash` |
31
+ | `approvalMode` | string? | `yolo` (default), `auto_edit`, `plan` (read-only) |
32
+ | `files` | string[]? | File paths to inject into Gemini's context |
33
+ | `includeDirectories` | string[]? | Additional directories for Gemini's workspace |
34
+ | `cwd` | string? | Working directory for the Gemini process |
35
+
36
+ ## TUI
37
+
38
+ - Collapsed: tool call list + response preview with streaming status
39
+ - Expanded (`Ctrl+O`): full markdown response with all tool calls and status icons
40
+
41
+ ## License
42
+
43
+ [MIT](LICENSE) © Agnish Chakraborty
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@agnishc/edb-gemini-proxy",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: gemini_proxy tool — delegate tasks to Google Gemini CLI from within pi",
5
+ "keywords": ["pi-package", "pi-extension", "edb", "gemini"],
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "Agnish Chakraborty",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
12
+ "directory": "packages/edb-gemini-proxy"
13
+ },
14
+ "homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-gemini-proxy#readme",
15
+ "bugs": { "url": "https://github.com/agnishcc/pi-extention-monorepo/issues" },
16
+ "publishConfig": { "access": "public" },
17
+ "scripts": { "test": "vitest run" },
18
+ "files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
19
+ "pi": {
20
+ "extensions": ["./src/index.ts"]
21
+ },
22
+ "peerDependencies": {
23
+ "@mariozechner/pi-coding-agent": "*",
24
+ "@mariozechner/pi-tui": "*",
25
+ "typebox": "*"
26
+ }
27
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { execSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ // ── CLI discovery ──────────────────────────────────────────────────────────────
7
+
8
+ export function findGeminiCli(): string {
9
+ if (process.env.GEMINI_PATH) {
10
+ try {
11
+ fs.accessSync(process.env.GEMINI_PATH, fs.constants.X_OK);
12
+ return process.env.GEMINI_PATH;
13
+ } catch {
14
+ /* fall through */
15
+ }
16
+ }
17
+
18
+ try {
19
+ const resolved = execSync("which gemini", {
20
+ encoding: "utf-8",
21
+ stdio: ["ignore", "pipe", "ignore"],
22
+ }).trim();
23
+ if (resolved) return resolved;
24
+ } catch {
25
+ /* fall through */
26
+ }
27
+
28
+ for (const c of [
29
+ "/opt/homebrew/bin/gemini",
30
+ path.join(os.homedir(), ".local/bin/gemini"),
31
+ "/usr/local/bin/gemini",
32
+ ]) {
33
+ try {
34
+ fs.accessSync(c, fs.constants.X_OK);
35
+ return c;
36
+ } catch {
37
+ /* continue */
38
+ }
39
+ }
40
+
41
+ return "gemini";
42
+ }
43
+
44
+ // ── File context ───────────────────────────────────────────────────────────────
45
+
46
+ export async function readFileForContext(absPath: string): Promise<string> {
47
+ try {
48
+ const content = await fs.promises.readFile(absPath, "utf-8");
49
+ return `<file path="${absPath}">\n${content}\n</file>`;
50
+ } catch (err) {
51
+ return `<file path="${absPath}" error="${(err as Error).message}" />`;
52
+ }
53
+ }
package/src/execute.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as path from "node:path";
3
+ import { readFileForContext } from "./cli";
4
+ import type { GeminiProxyDetails, GeminiStreamEvent, ToolCallRecord } from "./types";
5
+
6
+ // ── Execute ────────────────────────────────────────────────────────────────────
7
+
8
+ export async function execute(
9
+ geminiPath: string,
10
+ params: any,
11
+ signal: AbortSignal | undefined,
12
+ onUpdate: ((update: { content: Array<{ type: "text"; text: string }>; details?: any }) => void) | undefined,
13
+ ctx: any,
14
+ ): Promise<{ content: Array<{ type: string; text: string }>; details: GeminiProxyDetails; isError: boolean }> {
15
+ const workDir = params.cwd ?? ctx.cwd;
16
+ const approvalMode = params.approvalMode ?? "yolo";
17
+
18
+ // ----- 1. Build stdin payload -----
19
+ const stdinParts: string[] = [];
20
+ if (params.systemPrompt) {
21
+ stdinParts.push(`<instructions>\n${params.systemPrompt}\n</instructions>`);
22
+ }
23
+
24
+ const filesInjected: string[] = [];
25
+ if (params.files && params.files.length > 0) {
26
+ for (const filePath of params.files) {
27
+ const absPath = path.isAbsolute(filePath) ? filePath : path.join(workDir, filePath);
28
+ filesInjected.push(absPath);
29
+ stdinParts.push(await readFileForContext(absPath));
30
+ }
31
+ }
32
+ stdinParts.push(params.prompt);
33
+ const stdinPayload = stdinParts.join("\n\n");
34
+
35
+ // ----- 2. Build CLI arguments -----
36
+ const args: string[] = ["--output-format", "stream-json"];
37
+ if (params.model) args.push("--model", params.model);
38
+ if (approvalMode === "yolo") {
39
+ args.push("--yolo");
40
+ } else {
41
+ args.push("--approval-mode", approvalMode);
42
+ }
43
+ if (params.includeDirectories && params.includeDirectories.length > 0) {
44
+ for (const dir of params.includeDirectories) {
45
+ const absDir = path.isAbsolute(dir) ? dir : path.join(workDir, dir);
46
+ args.push("--include-directories", absDir);
47
+ }
48
+ }
49
+
50
+ // ----- 3. Spawn Gemini -----
51
+ const details: GeminiProxyDetails = {
52
+ streaming: true,
53
+ toolCalls: [],
54
+ filesInjected: filesInjected.length > 0 ? filesInjected : undefined,
55
+ };
56
+
57
+ const pendingToolCalls = new Map<string, ToolCallRecord>();
58
+ let assistantText = "";
59
+ let spawnError = "";
60
+ let _finalStatus: "success" | "error" = "success";
61
+ // eslint-disable-next-line prefer-const
62
+ let hadError = false;
63
+
64
+ const emitUpdate = () => {
65
+ onUpdate?.({
66
+ content: [{ type: "text" as const, text: assistantText || "(waiting for Gemini…)" }],
67
+ details: { ...details },
68
+ });
69
+ };
70
+
71
+ const exitCode = await new Promise<number>((resolve) => {
72
+ const proc = spawn(geminiPath, args, {
73
+ cwd: workDir,
74
+ shell: false,
75
+ stdio: ["pipe", "pipe", "pipe"],
76
+ env: { ...process.env },
77
+ });
78
+
79
+ proc.stdin.write(stdinPayload, "utf-8");
80
+ proc.stdin.end();
81
+
82
+ let buffer = "";
83
+
84
+ const processLine = (line: string) => {
85
+ if (!line.trim()) return;
86
+ let event: GeminiStreamEvent;
87
+ try {
88
+ event = JSON.parse(line) as GeminiStreamEvent;
89
+ } catch {
90
+ return;
91
+ }
92
+
93
+ switch (event.type) {
94
+ case "init": {
95
+ details.sessionId = event.session_id;
96
+ details.model = event.model;
97
+ break;
98
+ }
99
+ case "message": {
100
+ if (event.role !== "assistant") break;
101
+ if (event.delta) {
102
+ assistantText += event.content;
103
+ } else {
104
+ assistantText = event.content;
105
+ }
106
+ emitUpdate();
107
+ break;
108
+ }
109
+ case "tool_use": {
110
+ const record: ToolCallRecord = {
111
+ id: event.tool_id,
112
+ name: event.tool_name,
113
+ parameters: event.parameters,
114
+ };
115
+ pendingToolCalls.set(record.id, record);
116
+ details.toolCalls = [...details.toolCalls, record];
117
+ emitUpdate();
118
+ break;
119
+ }
120
+ case "tool_result": {
121
+ const record = pendingToolCalls.get(event.tool_id);
122
+ if (record) {
123
+ record.status = event.status;
124
+ record.output = event.output ?? event.error?.message;
125
+ details.toolCalls = details.toolCalls.map((t) => (t.id === event.tool_id ? { ...record } : t));
126
+ emitUpdate();
127
+ }
128
+ break;
129
+ }
130
+ case "error": {
131
+ if (event.severity === "error") spawnError += (spawnError ? "\n" : "") + event.message;
132
+ break;
133
+ }
134
+ case "result": {
135
+ details.streaming = false;
136
+ _finalStatus = event.status;
137
+ if (event.status === "error") hadError = true;
138
+ if (event.stats) {
139
+ details.totalTokens = event.stats.total_tokens;
140
+ details.durationMs = event.stats.duration_ms;
141
+ }
142
+ if (event.status === "error" && event.error) {
143
+ spawnError = event.error.message || "Gemini reported an error";
144
+ }
145
+ break;
146
+ }
147
+ }
148
+ };
149
+
150
+ proc.stdout.on("data", (chunk) => {
151
+ buffer += chunk.toString("utf-8");
152
+ const lines = buffer.split("\n");
153
+ buffer = lines.pop() ?? "";
154
+ for (const line of lines) processLine(line);
155
+ });
156
+
157
+ proc.stderr.on("data", (chunk) => {
158
+ const IGNORED = [
159
+ "Loaded cached credentials",
160
+ "YOLO mode is enabled",
161
+ "All tool calls will be automatically",
162
+ "Skill ",
163
+ "overriding the built-in",
164
+ "gemini-cli",
165
+ ];
166
+ const errorLines = chunk
167
+ .toString("utf-8")
168
+ .split("\n")
169
+ .filter((l: string) => l.trim() && !IGNORED.some((prefix) => l.includes(prefix)));
170
+ if (errorLines.length > 0) spawnError += (spawnError ? "\n" : "") + errorLines.join("\n");
171
+ });
172
+
173
+ proc.on("close", (code) => {
174
+ if (buffer.trim()) processLine(buffer);
175
+ details.exitCode = code ?? 0;
176
+ resolve(code ?? 0);
177
+ });
178
+
179
+ proc.on("error", (err) => {
180
+ spawnError = `Failed to spawn gemini CLI: ${err.message}\n\nMake sure 'gemini' is on your PATH (npm install -g @google/gemini-cli) or set GEMINI_PATH.`;
181
+ details.exitCode = 1;
182
+ resolve(1);
183
+ });
184
+
185
+ if (signal) {
186
+ const kill = () => {
187
+ proc.kill("SIGTERM");
188
+ setTimeout(() => {
189
+ if (!proc.killed) proc.kill("SIGKILL");
190
+ }, 5000);
191
+ };
192
+ if (signal.aborted) kill();
193
+ else signal.addEventListener("abort", kill, { once: true });
194
+ }
195
+ });
196
+
197
+ // ----- 4. Return result -----
198
+ const isError = exitCode !== 0 || hadError || (Boolean(spawnError) && !assistantText);
199
+ const responseText = isError ? spawnError || "(gemini exited with no output)" : assistantText || "(no output)";
200
+
201
+ return { content: [{ type: "text" as const, text: responseText }], details, isError };
202
+ }
package/src/format.ts ADDED
@@ -0,0 +1,47 @@
1
+ import * as os from "node:os";
2
+
3
+ // ── Format helpers ─────────────────────────────────────────────────────────────
4
+
5
+ /** Format a Gemini tool call for single-line display in the TUI. */
6
+ export function formatToolCall(name: string, params: Record<string, unknown>): string {
7
+ const shortenPath = (p: string) => {
8
+ const home = os.homedir();
9
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
10
+ };
11
+
12
+ // Gemini tool names follow snake_case conventions
13
+ switch (name) {
14
+ case "read_file": {
15
+ const p = shortenPath(String(params.path ?? params.absolute_path ?? "..."));
16
+ return `read ${p}`;
17
+ }
18
+ case "write_file":
19
+ case "edit_file":
20
+ case "replace_in_file": {
21
+ const p = shortenPath(String(params.path ?? params.absolute_path ?? "..."));
22
+ return `${name.replace(/_/g, " ")} ${p}`;
23
+ }
24
+ case "run_shell_command": {
25
+ const cmd = String(params.command ?? "...");
26
+ return `$ ${cmd.length > 60 ? `${cmd.slice(0, 60)}…` : cmd}`;
27
+ }
28
+ case "list_directory": {
29
+ const p = shortenPath(String(params.path ?? "."));
30
+ return `ls ${p}`;
31
+ }
32
+ case "search_file_content":
33
+ case "grep_search": {
34
+ const pattern = String(params.pattern ?? params.query ?? "...");
35
+ return `grep ${pattern.length > 30 ? `${pattern.slice(0, 30)}…` : pattern}`;
36
+ }
37
+ case "web_search":
38
+ case "google_web_search": {
39
+ const q = String(params.query ?? "...");
40
+ return `search: ${q.length > 50 ? `${q.slice(0, 50)}…` : q}`;
41
+ }
42
+ default: {
43
+ const s = JSON.stringify(params);
44
+ return `${name} ${s.length > 50 ? `${s.slice(0, 50)}…` : s}`;
45
+ }
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * pi-gemini-proxy
3
+ *
4
+ * Registers a `gemini_proxy` tool that lets the pi agent delegate tasks
5
+ * to Google's Gemini CLI running in non-interactive (headless) mode.
6
+ *
7
+ * Typical uses: code review, security audit, diff analysis, second-opinion
8
+ * reasoning — from a model with a different perspective than the primary agent.
9
+ *
10
+ * Requires: `gemini` CLI on PATH (npm install -g @google/gemini-cli)
11
+ * Optional: set GEMINI_PATH env var to point to a specific binary.
12
+ * Auth: GEMINI_API_KEY env var, or run `gemini auth` to login via OAuth.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+ import { findGeminiCli } from "./cli";
18
+ import { execute } from "./execute";
19
+ import { renderCall, renderResult } from "./render";
20
+
21
+ // ── Extension ──────────────────────────────────────────────────────────────────
22
+
23
+ export default function geminiProxyExtension(pi: ExtensionAPI): void {
24
+ const geminiPath = findGeminiCli();
25
+
26
+ pi.registerTool({
27
+ name: "gemini_proxy",
28
+ label: "Gemini Proxy",
29
+ description: [
30
+ "Delegate a task to Google's Gemini CLI running in non-interactive (headless) mode.",
31
+ "Best for: code review, security audit, diff analysis, cross-model second opinion,",
32
+ "tasks that benefit from Gemini's large context window or Google Search grounding.",
33
+ "Supply files[] to inject specific files into context.",
34
+ "Default approval mode is 'yolo' (auto-approve all tool actions).",
35
+ ].join(" "),
36
+ promptSnippet:
37
+ "Delegate a task to Gemini — triggers on 'ask gemini', 'check with gemini', 'have gemini look at', 'get gemini to', or requests for cross-model second opinion, large-context analysis, or Google Search grounding",
38
+ promptGuidelines: [
39
+ "Use gemini_proxy when the user says 'ask gemini', 'check with gemini', 'have gemini look at this', 'get gemini to', or any similar phrase that directs a task explicitly to Gemini.",
40
+ "Use gemini_proxy when the user wants a second opinion from Gemini on code, architecture, or security.",
41
+ "Use gemini_proxy with a tailored systemPrompt to give Gemini a specific expert persona.",
42
+ "Use gemini_proxy with approvalMode 'plan' for read-only analysis without any file modifications.",
43
+ "Pass relevant file paths in the files[] parameter so Gemini has precise context without needing to hunt for them.",
44
+ ],
45
+
46
+ parameters: Type.Object({
47
+ prompt: Type.String({
48
+ description:
49
+ "The task or question for Gemini. Be specific: include file names, what to look for, expected output format.",
50
+ }),
51
+ systemPrompt: Type.Optional(
52
+ Type.String({
53
+ description:
54
+ "Role / instructions prepended to the context. E.g. 'You are a senior security engineer. Identify vulnerabilities and rate their severity.'",
55
+ }),
56
+ ),
57
+ model: Type.Optional(
58
+ Type.String({
59
+ description:
60
+ "Gemini model to use. E.g. 'gemini-2.5-pro', 'gemini-2.5-flash'. Defaults to the CLI's configured default.",
61
+ }),
62
+ ),
63
+ approvalMode: Type.Optional(
64
+ Type.Union([Type.Literal("yolo"), Type.Literal("auto_edit"), Type.Literal("plan")], {
65
+ description:
66
+ "Tool approval mode. 'yolo' = auto-approve all (default), 'auto_edit' = auto-approve file edits only, 'plan' = read-only (no writes or shell).",
67
+ }),
68
+ ),
69
+ files: Type.Optional(
70
+ Type.Array(Type.String(), {
71
+ description:
72
+ "File paths (relative to cwd) whose full contents are injected into Gemini's context before the prompt.",
73
+ }),
74
+ ),
75
+ includeDirectories: Type.Optional(
76
+ Type.Array(Type.String(), {
77
+ description:
78
+ "Additional directory paths to include in Gemini's workspace (Gemini can read files from these dirs using its own Read tool).",
79
+ }),
80
+ ),
81
+ cwd: Type.Optional(
82
+ Type.String({
83
+ description: "Working directory for the Gemini process. Defaults to pi's current working directory.",
84
+ }),
85
+ ),
86
+ }),
87
+
88
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
89
+ return execute(geminiPath, params, signal, onUpdate as any, ctx) as any;
90
+ },
91
+
92
+ renderCall,
93
+ renderResult,
94
+ });
95
+ }
package/src/render.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
2
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
3
+ import { formatToolCall } from "./format";
4
+ import type { GeminiProxyDetails } from "./types";
5
+
6
+ // ── TUI rendering ──────────────────────────────────────────────────────────────
7
+
8
+ export function renderCall(args: any, theme: any): any {
9
+ const prompt = String(args.prompt ?? "");
10
+ const preview = prompt.length > 80 ? `${prompt.slice(0, 80)}…` : prompt;
11
+ const model = args.model ? String(args.model) : "default";
12
+ const mode = args.approvalMode ? String(args.approvalMode) : "yolo";
13
+
14
+ let text =
15
+ theme.fg("toolTitle", theme.bold("gemini_proxy ")) +
16
+ theme.fg("accent", `[${model}]`) +
17
+ theme.fg("muted", ` mode: ${mode}`);
18
+
19
+ if (args.systemPrompt) {
20
+ const rolePreview = String(args.systemPrompt).slice(0, 50);
21
+ text += `\n ${theme.fg("muted", "role: ")}${theme.fg("dim", rolePreview)}`;
22
+ }
23
+
24
+ text += `\n ${theme.fg("dim", preview)}`;
25
+
26
+ if (args.files && (args.files as string[]).length > 0) {
27
+ text += `\n ${theme.fg("muted", "files: ")}${theme.fg("dim", (args.files as string[]).join(", "))}`;
28
+ }
29
+
30
+ if (args.includeDirectories && (args.includeDirectories as string[]).length > 0) {
31
+ text += `\n ${theme.fg("muted", "dirs: ")}${theme.fg("dim", (args.includeDirectories as string[]).join(", "))}`;
32
+ }
33
+
34
+ return new Text(text, 0, 0);
35
+ }
36
+
37
+ export function renderResult(result: any, { expanded }: any, theme: any): any {
38
+ const details = result.details as GeminiProxyDetails | undefined;
39
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "(no output)";
40
+
41
+ const metaParts: string[] = [];
42
+ if (details?.model) metaParts.push(details.model);
43
+ if (details?.totalTokens) metaParts.push(`${details.totalTokens.toLocaleString()} tok`);
44
+ if (details?.durationMs) metaParts.push(`${(details.durationMs / 1000).toFixed(1)}s`);
45
+ const meta = metaParts.map((p) => theme.fg("dim", p)).join(" ");
46
+
47
+ if (result.isError) {
48
+ const errText = details?.streaming ? "(aborted)" : text;
49
+ return new Text(
50
+ theme.fg("error", "✗ ") +
51
+ theme.fg("toolTitle", theme.bold("Gemini")) +
52
+ (meta ? ` ${meta}` : "") +
53
+ `\n${theme.fg("error", errText)}`,
54
+ 0,
55
+ 0,
56
+ );
57
+ }
58
+
59
+ const icon = details?.streaming ? theme.fg("warning", "⏳") : theme.fg("success", "✓");
60
+ const headerLine = `${icon} ${theme.fg("toolTitle", theme.bold("Gemini"))}${meta ? ` ${meta}` : ""}`;
61
+
62
+ const toolCallLines = (details?.toolCalls ?? []).map((tc) => {
63
+ const statusIcon =
64
+ tc.status === "success"
65
+ ? theme.fg("success", "✓")
66
+ : tc.status === "error"
67
+ ? theme.fg("error", "✗")
68
+ : theme.fg("warning", "⏳");
69
+ return (
70
+ ` ${theme.fg("muted", "→ ")}${theme.fg("accent", tc.name)}` +
71
+ `${theme.fg("dim", ` ${formatToolCall(tc.name, tc.parameters)}`)} ${statusIcon}`
72
+ );
73
+ });
74
+
75
+ if (expanded) {
76
+ const mdTheme = getMarkdownTheme();
77
+ const container = new Container();
78
+ container.addChild(new Text(headerLine, 0, 0));
79
+ if (toolCallLines.length > 0) {
80
+ container.addChild(new Spacer(1));
81
+ container.addChild(new Text(theme.fg("muted", "─── Tool calls ───"), 0, 0));
82
+ for (const tl of toolCallLines) container.addChild(new Text(tl, 0, 0));
83
+ }
84
+ if (text) {
85
+ container.addChild(new Spacer(1));
86
+ container.addChild(new Text(theme.fg("muted", "─── Response ───"), 0, 0));
87
+ container.addChild(new Markdown(text.trim(), 0, 0, mdTheme));
88
+ }
89
+ return container;
90
+ }
91
+
92
+ // Collapsed view
93
+ const previewLines = text.split("\n").slice(0, 6);
94
+ const previewText = previewLines.join("\n") + (text.split("\n").length > 6 ? "\n…" : "");
95
+
96
+ let out = headerLine;
97
+ if (toolCallLines.length > 0) {
98
+ out += `\n${toolCallLines.slice(0, 3).join("\n")}`;
99
+ if (toolCallLines.length > 3) out += `\n ${theme.fg("muted", `… +${toolCallLines.length - 3} more`)}`;
100
+ }
101
+ if (text) out += `\n${theme.fg("toolOutput", previewText)}`;
102
+ out += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
103
+
104
+ return new Text(out, 0, 0);
105
+ }
package/src/types.ts ADDED
@@ -0,0 +1,85 @@
1
+ // ── Gemini stream-json event types ─────────────────────────────────────────────
2
+
3
+ export interface GeminiInitEvent {
4
+ type: "init";
5
+ timestamp: string;
6
+ session_id: string;
7
+ model: string;
8
+ }
9
+
10
+ export interface GeminiMessageEvent {
11
+ type: "message";
12
+ timestamp: string;
13
+ role: "user" | "assistant";
14
+ content: string;
15
+ /** true = streaming chunk; accumulate content. absent/false = full message */
16
+ delta?: boolean;
17
+ }
18
+
19
+ export interface GeminiToolUseEvent {
20
+ type: "tool_use";
21
+ timestamp: string;
22
+ tool_name: string;
23
+ tool_id: string;
24
+ parameters: Record<string, unknown>;
25
+ }
26
+
27
+ export interface GeminiToolResultEvent {
28
+ type: "tool_result";
29
+ timestamp: string;
30
+ tool_id: string;
31
+ status: "success" | "error";
32
+ output?: string;
33
+ error?: { type: string; message: string };
34
+ }
35
+
36
+ export interface GeminiErrorEvent {
37
+ type: "error";
38
+ timestamp: string;
39
+ severity: "warning" | "error";
40
+ message: string;
41
+ }
42
+
43
+ export interface GeminiResultEvent {
44
+ type: "result";
45
+ timestamp: string;
46
+ status: "success" | "error";
47
+ error?: { type: string; message: string };
48
+ stats?: {
49
+ total_tokens: number;
50
+ input_tokens: number;
51
+ output_tokens: number;
52
+ tool_calls: number;
53
+ duration_ms: number;
54
+ models: Record<string, { total_tokens: number; input_tokens: number; output_tokens: number }>;
55
+ };
56
+ }
57
+
58
+ export type GeminiStreamEvent =
59
+ | GeminiInitEvent
60
+ | GeminiMessageEvent
61
+ | GeminiToolUseEvent
62
+ | GeminiToolResultEvent
63
+ | GeminiErrorEvent
64
+ | GeminiResultEvent;
65
+
66
+ // ── Internal types ─────────────────────────────────────────────────────────────
67
+
68
+ export interface ToolCallRecord {
69
+ id: string;
70
+ name: string;
71
+ parameters: Record<string, unknown>;
72
+ status?: "success" | "error";
73
+ output?: string;
74
+ }
75
+
76
+ export interface GeminiProxyDetails {
77
+ sessionId?: string;
78
+ model?: string;
79
+ totalTokens?: number;
80
+ toolCalls: ToolCallRecord[];
81
+ streaming: boolean;
82
+ exitCode?: number;
83
+ filesInjected?: string[];
84
+ durationMs?: number;
85
+ }