feat(billing): implement G1 AI credits overage flow with billing telemetry (#18590)

This commit is contained in:
Gaurav
2026-02-27 10:15:06 -08:00
committed by GitHub
parent fdd844b405
commit b2d6844f9b
55 changed files with 3182 additions and 23 deletions

View File

@@ -80,6 +80,8 @@ describe('DialogManager', () => {
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
shouldShowIdePrompt: false,
isFolderTrustDialogOpen: false,
@@ -132,6 +134,8 @@ describe('DialogManager', () => {
resolve: vi.fn(),
},
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
'ProQuotaDialog',

View File

@@ -18,6 +18,8 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { OverageMenuDialog } from './OverageMenuDialog.js';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
@@ -152,6 +154,28 @@ export const DialogManager = ({
/>
);
}
if (uiState.quota.overageMenuRequest) {
return (
<OverageMenuDialog
failedModel={uiState.quota.overageMenuRequest.failedModel}
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
resetTime={uiState.quota.overageMenuRequest.resetTime}
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
onChoice={uiActions.handleOverageMenuChoice}
/>
);
}
if (uiState.quota.emptyWalletRequest) {
return (
<EmptyWalletDialog
failedModel={uiState.quota.emptyWalletRequest.failedModel}
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
resetTime={uiState.quota.emptyWalletRequest.resetTime}
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
onChoice={uiActions.handleEmptyWalletChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge

View File

@@ -0,0 +1,218 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
describe('EmptyWalletDialog', () => {
const mockOnChoice = vi.fn();
const mockOnGetCredits = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should match snapshot with fallback available', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
resetTime="2:00 PM"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should match snapshot without fallback', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display the model name and usage limit message', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('Usage limit reached');
unmount();
});
it('should display purchase prompt and credits update notice', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('purchase more AI Credits');
expect(output).toContain(
'Newly purchased AI credits may take a few minutes to update',
);
unmount();
});
it('should display reset time when provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('3:45 PM');
expect(output).toContain('Access resets at');
unmount();
});
it('should not display reset time when not provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).not.toContain('Access resets at');
unmount();
});
it('should display slash command hints', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('/stats');
expect(output).toContain('/model');
expect(output).toContain('/auth');
unmount();
});
});
describe('onChoice handling', () => {
it('should call onGetCredits and onChoice when get_credits is selected', async () => {
// get_credits is the first item, so just press Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
onGetCredits={mockOnGetCredits}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnGetCredits).toHaveBeenCalled();
expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
});
unmount();
});
it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
});
unmount();
});
it('should call onChoice with use_fallback when selected', async () => {
// With fallback: items are [get_credits, use_fallback, stop]
// use_fallback is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('use_fallback');
});
unmount();
});
it('should call onChoice with stop when selected', async () => {
// Without fallback: items are [get_credits, stop]
// stop is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('stop');
});
unmount();
});
});
});

View File

@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
/** Available choices in the empty wallet dialog */
export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop';
interface EmptyWalletDialogProps {
/** The model that hit the quota limit */
failedModel: string;
/** The fallback model to offer (omit if none available) */
fallbackModel?: string;
/** Time when access resets (human-readable) */
resetTime?: string;
/** Callback to log click and open the browser for purchasing credits */
onGetCredits?: () => void;
/** Callback when user makes a selection */
onChoice: (choice: EmptyWalletChoice) => void;
}
export function EmptyWalletDialog({
failedModel,
fallbackModel,
resetTime,
onGetCredits,
onChoice,
}: EmptyWalletDialogProps): React.JSX.Element {
const items: Array<{
label: string;
value: EmptyWalletChoice;
key: string;
}> = [
{
label: 'Get AI Credits - Open browser to purchase credits',
value: 'get_credits',
key: 'get_credits',
},
];
if (fallbackModel) {
items.push({
label: `Switch to ${fallbackModel}`,
value: 'use_fallback',
key: 'use_fallback',
});
}
items.push({
label: 'Stop - Abort request',
value: 'stop',
key: 'stop',
});
const handleSelect = (choice: EmptyWalletChoice) => {
if (choice === 'get_credits') {
onGetCredits?.();
}
onChoice(choice);
};
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1} flexDirection="column">
<Text color={theme.status.warning}>
Usage limit reached for {failedModel}.
</Text>
{resetTime && <Text>Access resets at {resetTime}.</Text>}
<Text>
<Text bold color={theme.text.accent}>
/stats
</Text>{' '}
model for usage details
</Text>
<Text>
<Text bold color={theme.text.accent}>
/model
</Text>{' '}
to switch models.
</Text>
<Text>
<Text bold color={theme.text.accent}>
/auth
</Text>{' '}
to switch to API key.
</Text>
</Box>
<Box marginBottom={1}>
<Text>To continue using this model now, purchase more AI Credits.</Text>
</Box>
<Box marginBottom={1}>
<Text dimColor>
Newly purchased AI credits may take a few minutes to update.
</Text>
</Box>
<Box marginBottom={1}>
<Text>How would you like to proceed?</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect items={items} onSelect={handleSelect} />
</Box>
</Box>
);
}

View File

@@ -177,6 +177,8 @@ describe('<Footer />', () => {
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
},
@@ -203,6 +205,8 @@ describe('<Footer />', () => {
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
},
@@ -229,6 +233,8 @@ describe('<Footer />', () => {
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
},

View File

@@ -146,6 +146,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
}
: undefined
}
creditBalance={itemForDisplay.creditBalance}
/>
)}
{itemForDisplay.type === 'model_stats' && (

View File

@@ -0,0 +1,228 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { OverageMenuDialog } from './OverageMenuDialog.js';
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
describe('OverageMenuDialog', () => {
const mockOnChoice = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should match snapshot with fallback available', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
resetTime="2:00 PM"
creditBalance={500}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should match snapshot without fallback', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={500}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display the credit balance', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={200}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('200');
expect(output).toContain('AI Credits available');
unmount();
});
it('should display the model name', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('Usage limit reached');
unmount();
});
it('should display reset time when provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('3:45 PM');
expect(output).toContain('Access resets at');
unmount();
});
it('should not display reset time when not provided', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).not.toContain('Access resets at');
unmount();
});
it('should display slash command hints', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('/stats');
expect(output).toContain('/model');
expect(output).toContain('/auth');
unmount();
});
});
describe('onChoice handling', () => {
it('should call onChoice with use_credits when selected', async () => {
// use_credits is the first item, so just press Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('use_credits');
});
unmount();
});
it('should call onChoice with manage when selected', async () => {
// manage is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('manage');
});
unmount();
});
it('should call onChoice with use_fallback when selected', async () => {
// With fallback: items are [use_credits, manage, use_fallback, stop]
// use_fallback is the third item: Down x2 + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('use_fallback');
});
unmount();
});
it('should call onChoice with stop when selected', async () => {
// Without fallback: items are [use_credits, manage, stop]
// stop is the third item: Down x2 + Enter
const { unmount, stdin, waitUntilReady } = renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockOnChoice).toHaveBeenCalledWith('stop');
});
unmount();
});
});
});

View File

@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
/** Available choices in the overage menu dialog */
export type OverageMenuChoice =
| 'use_credits'
| 'use_fallback'
| 'manage'
| 'stop';
interface OverageMenuDialogProps {
/** The model that hit the quota limit */
failedModel: string;
/** The fallback model to offer (omit if none available) */
fallbackModel?: string;
/** Time when access resets (human-readable) */
resetTime?: string;
/** Available G1 AI credit balance */
creditBalance: number;
/** Callback when user makes a selection */
onChoice: (choice: OverageMenuChoice) => void;
}
export function OverageMenuDialog({
failedModel,
fallbackModel,
resetTime,
creditBalance,
onChoice,
}: OverageMenuDialogProps): React.JSX.Element {
const items: Array<{
label: string;
value: OverageMenuChoice;
key: string;
}> = [
{
label: 'Use AI Credits - Continue this request (Overage)',
value: 'use_credits',
key: 'use_credits',
},
{
label: 'Manage - View balance and purchase more credits',
value: 'manage',
key: 'manage',
},
];
if (fallbackModel) {
items.push({
label: `Switch to ${fallbackModel}`,
value: 'use_fallback',
key: 'use_fallback',
});
}
items.push({
label: 'Stop - Abort request',
value: 'stop',
key: 'stop',
});
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1} flexDirection="column">
<Text color={theme.status.warning}>
Usage limit reached for {failedModel}.
</Text>
{resetTime && <Text>Access resets at {resetTime}.</Text>}
<Text>
<Text bold color={theme.text.accent}>
/stats
</Text>{' '}
model for usage details
</Text>
<Text>
<Text bold color={theme.text.accent}>
/model
</Text>{' '}
to switch models.
</Text>
<Text>
<Text bold color={theme.text.accent}>
/auth
</Text>{' '}
to switch to API key.
</Text>
</Box>
<Box marginBottom={1}>
<Text>
You have{' '}
<Text bold color={theme.status.success}>
{creditBalance}
</Text>{' '}
AI Credits available.
</Text>
</Box>
<Box marginBottom={1}>
<Text>How would you like to proceed?</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect items={items} onSelect={onChoice} />
</Box>
</Box>
);
}

View File

@@ -395,6 +395,7 @@ interface StatsDisplayProps {
tier?: string;
currentModel?: string;
quotaStats?: QuotaStats;
creditBalance?: number;
}
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
@@ -407,6 +408,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
tier,
currentModel,
quotaStats,
creditBalance,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
@@ -488,6 +490,17 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
<Text color={theme.text.primary}>{tier}</Text>
</StatRow>
)}
{showUserIdentity && creditBalance != null && creditBalance >= 0 && (
<StatRow title="Google AI Credits:">
<Text
color={
creditBalance > 0 ? theme.text.primary : theme.text.secondary
}
>
{creditBalance.toLocaleString()}
</Text>
</StatRow>
)}
<StatRow title="Tool Calls:">
<Text color={theme.text.primary}>
{tools.totalCalls} ({' '}

View File

@@ -0,0 +1,49 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`EmptyWalletDialog > rendering > should match snapshot with fallback available 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ Access resets at 2:00 PM. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ To continue using this model now, purchase more AI Credits. │
│ │
│ Newly purchased AI credits may take a few minutes to update. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Get AI Credits - Open browser to purchase credits │
│ 2. Switch to gemini-3-flash-preview │
│ 3. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`EmptyWalletDialog > rendering > should match snapshot without fallback 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ To continue using this model now, purchase more AI Credits. │
│ │
│ Newly purchased AI credits may take a few minutes to update. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Get AI Credits - Open browser to purchase credits │
│ 2. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;

View File

@@ -0,0 +1,47 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`OverageMenuDialog > rendering > should match snapshot with fallback available 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ Access resets at 2:00 PM. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ You have 500 AI Credits available. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Use AI Credits - Continue this request (Overage) │
│ 2. Manage - View balance and purchase more credits │
│ 3. Switch to gemini-3-flash-preview │
│ 4. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`OverageMenuDialog > rendering > should match snapshot without fallback 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Usage limit reached for gemini-2.5-pro. │
│ /stats model for usage details │
│ /model to switch models. │
│ /auth to switch to API key. │
│ │
│ You have 500 AI Credits available. │
│ │
│ How would you like to proceed? │
│ │
│ │
│ ● 1. Use AI Credits - Continue this request (Overage) │
│ 2. Manage - View balance and purchase more credits │
│ 3. Stop - Abort request │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;