@astrofoundry/pi-astro 0.9.0 → 0.11.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.
@@ -0,0 +1,222 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ type Handler = (event: unknown, ctx: unknown) => void | Promise<void>;
4
+ type CommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
5
+
6
+ interface FakePi {
7
+ handlers: Record<string, Handler>;
8
+ commands: Map<string, { handler: CommandHandler; getArgumentCompletions?: (p: string) => unknown }>;
9
+ getThinkingLevel: ReturnType<typeof vi.fn>;
10
+ on: (event: string, h: Handler) => void;
11
+ registerCommand: (
12
+ name: string,
13
+ opts: {
14
+ description?: string;
15
+ handler: CommandHandler;
16
+ getArgumentCompletions?: (p: string) => unknown;
17
+ },
18
+ ) => void;
19
+ }
20
+
21
+ function makePi(): FakePi {
22
+ const handlers: Record<string, Handler> = {};
23
+ const commands = new Map<string, { handler: CommandHandler; getArgumentCompletions?: (p: string) => unknown }>();
24
+ return {
25
+ handlers,
26
+ commands,
27
+ getThinkingLevel: vi.fn(() => "medium"),
28
+ on: (event, h) => {
29
+ handlers[event] = h;
30
+ },
31
+ registerCommand: (name, opts) => {
32
+ commands.set(name, {
33
+ handler: opts.handler,
34
+ getArgumentCompletions: opts.getArgumentCompletions,
35
+ });
36
+ },
37
+ };
38
+ }
39
+
40
+ interface FakeCtx {
41
+ ui: {
42
+ notify: ReturnType<typeof vi.fn>;
43
+ setFooter: ReturnType<typeof vi.fn>;
44
+ };
45
+ cwd: string;
46
+ model: { id: string } | undefined;
47
+ sessionManager: { getBranch: () => unknown[] };
48
+ getContextUsage: () => { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
49
+ }
50
+
51
+ function makeCtx(overrides: Partial<FakeCtx> = {}): FakeCtx {
52
+ return {
53
+ ui: {
54
+ notify: vi.fn(),
55
+ setFooter: vi.fn(),
56
+ },
57
+ cwd: "/Users/astro/proj",
58
+ model: { id: "anthropic/claude-sonnet-4-6" },
59
+ sessionManager: { getBranch: () => [] },
60
+ getContextUsage: () => undefined,
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe("astro-footer extension", () => {
66
+ const origEnv = { ...process.env };
67
+
68
+ beforeEach(() => {
69
+ vi.resetModules();
70
+ delete process.env.ASTRO_FOOTER_NERD_FONTS;
71
+ delete process.env.TERM_PROGRAM;
72
+ delete process.env.TERM;
73
+ delete process.env.LC_TERMINAL;
74
+ });
75
+
76
+ afterEach(() => {
77
+ for (const k of Object.keys(process.env)) {
78
+ if (!(k in origEnv)) delete process.env[k];
79
+ }
80
+ for (const [k, v] of Object.entries(origEnv)) {
81
+ process.env[k] = v;
82
+ }
83
+ });
84
+
85
+ async function load(): Promise<typeof import("./index.ts")> {
86
+ return await import("./index.ts");
87
+ }
88
+
89
+ async function install(): Promise<{ pi: FakePi; mod: Awaited<ReturnType<typeof load>> }> {
90
+ const mod = await load();
91
+ const pi = makePi();
92
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
93
+ return { pi, mod };
94
+ }
95
+
96
+ it("registers /footer command and session_start handler", async () => {
97
+ const { pi } = await install();
98
+ expect(pi.commands.has("footer")).toBe(true);
99
+ expect(pi.handlers.session_start).toBeDefined();
100
+ });
101
+
102
+ it("session_start activates the footer via ctx.ui.setFooter", async () => {
103
+ const { pi } = await install();
104
+ const ctx = makeCtx();
105
+ await pi.handlers.session_start({}, ctx);
106
+ expect(ctx.ui.setFooter).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("/footer status reports current state", async () => {
110
+ const { pi } = await install();
111
+ const ctx = makeCtx();
112
+ await pi.commands.get("footer")!.handler("status", ctx);
113
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/astro-footer: on/), "info");
114
+ });
115
+
116
+ it("/footer off restores the default footer", async () => {
117
+ const { pi } = await install();
118
+ const ctx = makeCtx();
119
+ await pi.commands.get("footer")!.handler("off", ctx);
120
+ expect(ctx.ui.setFooter).toHaveBeenCalledWith(undefined);
121
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/off/), "info");
122
+ });
123
+
124
+ it("/footer on re-activates after being off", async () => {
125
+ const { pi } = await install();
126
+ const ctx = makeCtx();
127
+ await pi.commands.get("footer")!.handler("off", ctx);
128
+ await pi.commands.get("footer")!.handler("on", ctx);
129
+ const setFooterCalls = ctx.ui.setFooter.mock.calls;
130
+ expect(setFooterCalls[0][0]).toBeUndefined();
131
+ expect(typeof setFooterCalls[1][0]).toBe("function");
132
+ });
133
+
134
+ it("/footer with unknown subcommand warns", async () => {
135
+ const { pi } = await install();
136
+ const ctx = makeCtx();
137
+ await pi.commands.get("footer")!.handler("nonsense", ctx);
138
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/unknown subcommand/), "warning");
139
+ });
140
+
141
+ it("argument autocomplete returns on/off/status", async () => {
142
+ const { pi } = await install();
143
+ const completions = pi.commands.get("footer")!.getArgumentCompletions!("");
144
+ expect(completions).toEqual(
145
+ expect.arrayContaining([
146
+ { value: "on", label: "on" },
147
+ { value: "off", label: "off" },
148
+ { value: "status", label: "status" },
149
+ ]),
150
+ );
151
+ });
152
+
153
+ it("renders a single-line footer with model, path, git, and extension statuses", async () => {
154
+ process.env.ASTRO_FOOTER_NERD_FONTS = "0";
155
+ const { pi } = await install();
156
+ const ctx = makeCtx({
157
+ sessionManager: {
158
+ getBranch: () => [
159
+ {
160
+ type: "message",
161
+ message: {
162
+ role: "assistant",
163
+ usage: { input: 1000, output: 200, cost: { total: 0.05 } },
164
+ },
165
+ },
166
+ ],
167
+ },
168
+ getContextUsage: () => ({ tokens: 50, contextWindow: 200_000, percent: 0.25 }),
169
+ });
170
+ await pi.handlers.session_start({}, ctx);
171
+ const factory = ctx.ui.setFooter.mock.calls[0][0] as (
172
+ tui: unknown,
173
+ theme: unknown,
174
+ footerData: unknown,
175
+ ) => { render: (w: number) => string[] };
176
+ const fakeTheme = {
177
+ fg: (_role: string, text: string) => text,
178
+ bold: (text: string) => text,
179
+ };
180
+ const fakeFooterData = {
181
+ getGitBranch: () => "main",
182
+ getExtensionStatuses: () => new Map([["caveman", "🪨 caveman:full"]]),
183
+ getAvailableProviderCount: () => 1,
184
+ onBranchChange: () => () => {},
185
+ };
186
+ const component = factory({}, fakeTheme, fakeFooterData);
187
+ const lines = component.render(120);
188
+ expect(lines).toHaveLength(1);
189
+ const line = lines[0];
190
+ expect(line).toContain("claude-sonnet-4-6");
191
+ expect(line).toContain("proj");
192
+ expect(line).toContain("main");
193
+ expect(line).toContain("1.2k");
194
+ expect(line).toContain("$0.05");
195
+ expect(line).toContain("25%");
196
+ expect(line).toContain("🪨 caveman:full");
197
+ });
198
+
199
+ it("renders without right side when there's nothing to show", async () => {
200
+ process.env.ASTRO_FOOTER_NERD_FONTS = "0";
201
+ const { pi } = await install();
202
+ const ctx = makeCtx();
203
+ await pi.handlers.session_start({}, ctx);
204
+ const factory = ctx.ui.setFooter.mock.calls[0][0] as (
205
+ tui: unknown,
206
+ theme: unknown,
207
+ footerData: unknown,
208
+ ) => { render: (w: number) => string[] };
209
+ const fakeTheme = { fg: (_role: string, text: string) => text };
210
+ const fakeFooterData = {
211
+ getGitBranch: () => null,
212
+ getExtensionStatuses: () => new Map(),
213
+ getAvailableProviderCount: () => 1,
214
+ onBranchChange: () => () => {},
215
+ };
216
+ const component = factory({}, fakeTheme, fakeFooterData);
217
+ const lines = component.render(80);
218
+ expect(lines).toHaveLength(1);
219
+ expect(lines[0]).not.toContain("$");
220
+ expect(lines[0]).toContain("proj");
221
+ });
222
+ });
@@ -0,0 +1,146 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ReadonlyFooterDataProvider,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
7
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
8
+ import { detectNerdFonts, iconsFor, type IconSet } from "./icons.ts";
9
+ import {
10
+ renderContext,
11
+ renderCost,
12
+ renderExtensionStatus,
13
+ renderGit,
14
+ renderModel,
15
+ renderPath,
16
+ renderPi,
17
+ renderThinking,
18
+ renderTokens,
19
+ type ThemeFn,
20
+ } from "./segments.ts";
21
+
22
+ interface UsageTotals {
23
+ input: number;
24
+ output: number;
25
+ cost: number;
26
+ }
27
+
28
+ function collectUsage(ctx: ExtensionContext): UsageTotals {
29
+ const totals: UsageTotals = { input: 0, output: 0, cost: 0 };
30
+ for (const entry of ctx.sessionManager.getBranch()) {
31
+ if (entry.type !== "message") continue;
32
+ if (entry.message.role !== "assistant") continue;
33
+ const msg = entry.message as AssistantMessage;
34
+ totals.input += msg.usage.input;
35
+ totals.output += msg.usage.output;
36
+ totals.cost += msg.usage.cost.total;
37
+ }
38
+ return totals;
39
+ }
40
+
41
+ function buildLine(
42
+ theme: ThemeFn,
43
+ icons: IconSet,
44
+ ctx: ExtensionContext,
45
+ pi: ExtensionAPI,
46
+ footerData: ReadonlyFooterDataProvider,
47
+ width: number,
48
+ ): string {
49
+ const usage = collectUsage(ctx);
50
+ const totalTokens = usage.input + usage.output;
51
+ const contextUsage = ctx.getContextUsage();
52
+ const thinkingLevel = pi.getThinkingLevel();
53
+ const branch = footerData.getGitBranch();
54
+ const statuses = footerData.getExtensionStatuses();
55
+
56
+ const left = [
57
+ renderPi(theme, icons),
58
+ renderModel(theme, icons, ctx.model?.id),
59
+ renderThinking(theme, icons, thinkingLevel),
60
+ renderPath(theme, icons, ctx.cwd),
61
+ renderGit(theme, icons, branch),
62
+ ].filter((s): s is string => Boolean(s));
63
+
64
+ const right: string[] = [];
65
+ const tokenSegment = renderTokens(theme, icons, totalTokens);
66
+ if (tokenSegment) right.push(tokenSegment);
67
+ const costSegment = renderCost(theme, icons, usage.cost);
68
+ if (costSegment) right.push(costSegment);
69
+ const contextSegment = renderContext(theme, icons, contextUsage?.percent ?? null);
70
+ if (contextSegment) right.push(contextSegment);
71
+ for (const [, value] of statuses) {
72
+ if (value) right.push(renderExtensionStatus(theme, value));
73
+ }
74
+
75
+ const sep = ` ${theme.fg("border", icons.separator)} `;
76
+ const leftStr = left.join(sep);
77
+ const rightStr = right.join(sep);
78
+
79
+ if (rightStr.length === 0) return truncateToWidth(leftStr, width);
80
+
81
+ const padWidth = Math.max(1, width - visibleWidth(leftStr) - visibleWidth(rightStr));
82
+ return truncateToWidth(leftStr + " ".repeat(padWidth) + rightStr, width);
83
+ }
84
+
85
+ export default function astroFooterExtension(pi: ExtensionAPI): void {
86
+ const useNerd = detectNerdFonts();
87
+ const icons = iconsFor(useNerd);
88
+
89
+ let enabled = true;
90
+
91
+ function activate(ctx: ExtensionContext): void {
92
+ ctx.ui.setFooter((_tui, theme, footerData) => {
93
+ const themeFn: ThemeFn = {
94
+ fg: (role, text) => theme.fg(role, text),
95
+ };
96
+ return {
97
+ invalidate() {},
98
+ render(width: number): string[] {
99
+ return [buildLine(themeFn, icons, ctx, pi, footerData, width)];
100
+ },
101
+ };
102
+ });
103
+ }
104
+
105
+ pi.on("session_start", async (_event, ctx) => {
106
+ if (enabled) activate(ctx);
107
+ });
108
+
109
+ pi.registerCommand("footer", {
110
+ description: "Toggle astro-footer. Usage: /footer [on|off|status].",
111
+ getArgumentCompletions: (prefix) => {
112
+ const items = ["on", "off", "status"];
113
+ const lower = prefix.toLowerCase();
114
+ const matches = items
115
+ .filter((value) => value.startsWith(lower))
116
+ .map((value) => ({ value, label: value }));
117
+ return matches.length > 0 ? matches : null;
118
+ },
119
+ handler: async (args, ctx) => {
120
+ const sub = args.trim().toLowerCase() || "status";
121
+ if (sub === "status") {
122
+ ctx.ui.notify(
123
+ enabled ? `astro-footer: on (${useNerd ? "nerd" : "ascii"} icons)` : "astro-footer: off",
124
+ "info",
125
+ );
126
+ return;
127
+ }
128
+ if (sub === "on") {
129
+ enabled = true;
130
+ activate(ctx);
131
+ ctx.ui.notify("astro-footer: on", "info");
132
+ return;
133
+ }
134
+ if (sub === "off") {
135
+ enabled = false;
136
+ ctx.ui.setFooter(undefined);
137
+ ctx.ui.notify("astro-footer: off (default footer restored)", "info");
138
+ return;
139
+ }
140
+ ctx.ui.notify(
141
+ `astro-footer: unknown subcommand "${sub}". Use on|off|status.`,
142
+ "warning",
143
+ );
144
+ },
145
+ });
146
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { iconsFor } from "./icons.ts";
3
+ import {
4
+ renderContext,
5
+ renderCost,
6
+ renderExtensionStatus,
7
+ renderGit,
8
+ renderModel,
9
+ renderPath,
10
+ renderPi,
11
+ renderThinking,
12
+ renderTokens,
13
+ type ThemeFn,
14
+ } from "./segments.ts";
15
+
16
+ const fakeTheme: ThemeFn = {
17
+ fg: (role, text) => `<${role}>${text}</${role}>`,
18
+ };
19
+
20
+ const ICONS = iconsFor(false);
21
+
22
+ describe("segments", () => {
23
+ it("renderPi shows pi icon with accent color", () => {
24
+ expect(renderPi(fakeTheme, ICONS)).toBe("<accent>π</accent>");
25
+ });
26
+
27
+ it("renderModel returns null when no model", () => {
28
+ expect(renderModel(fakeTheme, ICONS, undefined)).toBeNull();
29
+ });
30
+
31
+ it("renderModel uses the last segment of a slash-separated id", () => {
32
+ const out = renderModel(fakeTheme, ICONS, "anthropic/claude-sonnet-4-6");
33
+ expect(out).toContain("claude-sonnet-4-6");
34
+ expect(out).not.toContain("anthropic/");
35
+ });
36
+
37
+ it("renderThinking returns null when level missing", () => {
38
+ expect(renderThinking(fakeTheme, ICONS, undefined)).toBeNull();
39
+ });
40
+
41
+ it("renderThinking labels each level", () => {
42
+ expect(renderThinking(fakeTheme, ICONS, "off")).toContain("think:off");
43
+ expect(renderThinking(fakeTheme, ICONS, "minimal")).toContain("think:min");
44
+ expect(renderThinking(fakeTheme, ICONS, "low")).toContain("think:low");
45
+ expect(renderThinking(fakeTheme, ICONS, "medium")).toContain("think:med");
46
+ expect(renderThinking(fakeTheme, ICONS, "high")).toContain("think:high");
47
+ expect(renderThinking(fakeTheme, ICONS, "xhigh")).toContain("think:xhi");
48
+ });
49
+
50
+ it("renderThinking falls back gracefully on unknown level", () => {
51
+ const out = renderThinking(fakeTheme, ICONS, "weird");
52
+ expect(out).toContain("think:weird");
53
+ });
54
+
55
+ it("renderPath shows the basename of cwd", () => {
56
+ const out = renderPath(fakeTheme, ICONS, "/Users/astro/proj");
57
+ expect(out).toContain("proj");
58
+ });
59
+
60
+ it("renderGit returns null when no branch", () => {
61
+ expect(renderGit(fakeTheme, ICONS, null)).toBeNull();
62
+ });
63
+
64
+ it("renderGit shows branch name in success colour", () => {
65
+ const out = renderGit(fakeTheme, ICONS, "feat-foo");
66
+ expect(out).toContain("<success>feat-foo</success>");
67
+ });
68
+
69
+ it("renderTokens hides when total is zero", () => {
70
+ expect(renderTokens(fakeTheme, ICONS, 0)).toBeNull();
71
+ });
72
+
73
+ it("renderTokens formats large totals", () => {
74
+ expect(renderTokens(fakeTheme, ICONS, 1234)).toContain("1.2k");
75
+ });
76
+
77
+ it("renderCost hides when zero", () => {
78
+ expect(renderCost(fakeTheme, ICONS, 0)).toBeNull();
79
+ });
80
+
81
+ it("renderCost formats sub-dollar costs", () => {
82
+ expect(renderCost(fakeTheme, ICONS, 0.123)).toContain("$0.123");
83
+ });
84
+
85
+ it("renderContext hides when percent is null", () => {
86
+ expect(renderContext(fakeTheme, ICONS, null)).toBeNull();
87
+ });
88
+
89
+ it("renderContext uses dim colour below 70%", () => {
90
+ const out = renderContext(fakeTheme, ICONS, 0.5);
91
+ expect(out).toContain("<dim>");
92
+ });
93
+
94
+ it("renderContext uses warning colour at and above 70%", () => {
95
+ const out = renderContext(fakeTheme, ICONS, 0.7);
96
+ expect(out).toContain("<warning>");
97
+ });
98
+
99
+ it("renderContext uses error colour at and above 90%", () => {
100
+ const out = renderContext(fakeTheme, ICONS, 0.95);
101
+ expect(out).toContain("<error>");
102
+ });
103
+
104
+ it("renderExtensionStatus uses muted colour", () => {
105
+ expect(renderExtensionStatus(fakeTheme, "🪨 caveman:full")).toBe("<muted>🪨 caveman:full</muted>");
106
+ });
107
+ });
@@ -0,0 +1,76 @@
1
+ import type { ThemeColor } from "@mariozechner/pi-coding-agent";
2
+ import type { IconSet } from "./icons.ts";
3
+ import { formatCost, formatPathBasename, formatPercent, formatTokens } from "./format.ts";
4
+
5
+ export interface ThemeFn {
6
+ fg: (role: ThemeColor, text: string) => string;
7
+ }
8
+
9
+ const THINKING_LABELS: Record<string, string> = {
10
+ off: "off",
11
+ minimal: "min",
12
+ low: "low",
13
+ medium: "med",
14
+ high: "high",
15
+ xhigh: "xhi",
16
+ };
17
+
18
+ const THINKING_COLORS: Record<string, ThemeColor> = {
19
+ off: "dim",
20
+ minimal: "muted",
21
+ low: "accent",
22
+ medium: "accent",
23
+ high: "warning",
24
+ xhigh: "error",
25
+ };
26
+
27
+ export function renderPi(theme: ThemeFn, icons: IconSet): string {
28
+ return theme.fg("accent", icons.pi);
29
+ }
30
+
31
+ export function renderModel(theme: ThemeFn, icons: IconSet, modelId: string | undefined): string | null {
32
+ if (!modelId) return null;
33
+ const short = modelId.split("/").pop() ?? modelId;
34
+ return `${theme.fg("muted", icons.model)} ${theme.fg("text", short)}`.trim();
35
+ }
36
+
37
+ export function renderThinking(theme: ThemeFn, icons: IconSet, level: string | undefined): string | null {
38
+ if (!level) return null;
39
+ const label = THINKING_LABELS[level] ?? level;
40
+ const color = THINKING_COLORS[level] ?? "dim";
41
+ return `${theme.fg(color, icons.thinking)} ${theme.fg(color, `think:${label}`)}`.trim();
42
+ }
43
+
44
+ export function renderPath(theme: ThemeFn, icons: IconSet, cwd: string): string {
45
+ const name = formatPathBasename(cwd);
46
+ return `${theme.fg("dim", icons.path)} ${theme.fg("accent", name)}`.trim();
47
+ }
48
+
49
+ export function renderGit(theme: ThemeFn, icons: IconSet, branch: string | null): string | null {
50
+ if (!branch) return null;
51
+ return `${theme.fg("success", icons.git)} ${theme.fg("success", branch)}`.trim();
52
+ }
53
+
54
+ export function renderTokens(theme: ThemeFn, icons: IconSet, totalTokens: number): string | null {
55
+ if (totalTokens === 0) return null;
56
+ return `${theme.fg("muted", icons.tokens)} ${theme.fg("text", formatTokens(totalTokens))}`.trim();
57
+ }
58
+
59
+ export function renderCost(theme: ThemeFn, icons: IconSet, costUsd: number): string | null {
60
+ if (costUsd === 0) return null;
61
+ return `${theme.fg("muted", icons.cost)} ${theme.fg("text", formatCost(costUsd))}`.trim();
62
+ }
63
+
64
+ export function renderContext(
65
+ theme: ThemeFn,
66
+ icons: IconSet,
67
+ percent: number | null,
68
+ ): string | null {
69
+ if (percent === null) return null;
70
+ const role: ThemeColor = percent >= 0.9 ? "error" : percent >= 0.7 ? "warning" : "dim";
71
+ return `${theme.fg(role, icons.context)} ${theme.fg(role, formatPercent(percent))}`.trim();
72
+ }
73
+
74
+ export function renderExtensionStatus(theme: ThemeFn, value: string): string {
75
+ return theme.fg("muted", value);
76
+ }
@@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
4
4
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
5
5
  import { GoogleGenAI } from "@google/genai";
6
6
  import { StringEnum } from "@mariozechner/pi-ai";
7
+ import { Text } from "@mariozechner/pi-tui";
7
8
  import { Type } from "typebox";
8
9
  import { persistApiKey, resolveExistingApiKey } from "./credentials.ts";
9
10
  import {
@@ -72,6 +73,13 @@ type Params = {
72
73
  skip_confirm?: boolean;
73
74
  };
74
75
 
76
+ interface GeminiImageDetails {
77
+ model: ImageModel;
78
+ images: number;
79
+ savedPaths: string[];
80
+ actualCostUsd?: number;
81
+ }
82
+
75
83
  async function ensureApiKey(ctx: ExtensionContext): Promise<string> {
76
84
  const existing = resolveExistingApiKey();
77
85
  if (existing) return existing;
@@ -410,15 +418,35 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
410
418
  ...call.images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
411
419
  ];
412
420
 
413
- return {
414
- content,
415
- details: {
416
- model: finalModel,
417
- images: call.images.length,
418
- savedPaths,
419
- actualCostUsd: actual?.usd,
420
- },
421
+ const details: GeminiImageDetails = {
422
+ model: finalModel,
423
+ images: call.images.length,
424
+ savedPaths,
425
+ actualCostUsd: actual?.usd,
421
426
  };
427
+
428
+ return { content, details };
429
+ },
430
+ renderResult(result, { expanded, isPartial }, theme, _context) {
431
+ if (isPartial) {
432
+ return new Text(theme.fg("warning", "Generating..."), 0, 0);
433
+ }
434
+ const details = result.details as GeminiImageDetails | undefined;
435
+ if (!details) {
436
+ return new Text(theme.fg("dim", "(no details)"), 0, 0);
437
+ }
438
+ const plural = details.images === 1 ? "image" : "images";
439
+ let text = theme.fg("toolTitle", theme.bold(details.model));
440
+ text += theme.fg("muted", ` · ${details.images} ${plural}`);
441
+ if (typeof details.actualCostUsd === "number") {
442
+ text += theme.fg("dim", ` · $${details.actualCostUsd.toFixed(4)}`);
443
+ }
444
+ if (expanded) {
445
+ for (const path of details.savedPaths) {
446
+ text += "\n" + theme.fg("dim", ` ${path}`);
447
+ }
448
+ }
449
+ return new Text(text, 0, 0);
422
450
  },
423
451
  });
424
452
  }