mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 01:51:20 -07:00
Merge pull request #19001 from statusline
This commit is contained in:
@@ -43,39 +43,37 @@ they appear in the UI.
|
||||
|
||||
### UI
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| New Footer Layout | `ui.newFooterLayout` | Use the new 2-row layout with inline tips. | `"legacy"` |
|
||||
| Show Tips | `ui.showTips` | Show informative tips on the right side of the status line. | `true` |
|
||||
| Show Witty Phrases | `ui.showWit` | Show witty phrases while waiting. | `true` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
|
||||
### IDE
|
||||
|
||||
|
||||
@@ -255,8 +255,18 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
input.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.footer.items`** (array):
|
||||
- **Description:** List of item IDs to display in the footer. Rendered in
|
||||
order
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`ui.footer.showLabels`** (boolean):
|
||||
- **Description:** Display a second line above the footer items with
|
||||
descriptive headers (e.g., /model).
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.footer.hideCWD`** (boolean):
|
||||
- **Description:** Hide the current working directory path in the footer.
|
||||
- **Description:** Hide the current working directory in the footer.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.footer.hideSandboxStatus`** (boolean):
|
||||
|
||||
91
packages/cli/src/config/footerItems.test.ts
Normal file
91
packages/cli/src/config/footerItems.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveItemsFromLegacySettings } from './footerItems.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
|
||||
describe('deriveItemsFromLegacySettings', () => {
|
||||
it('returns defaults when no legacy settings are customized', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'cwd',
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'model-name',
|
||||
'quota',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes cwd when hideCWD is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideCWD: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('cwd');
|
||||
});
|
||||
|
||||
it('removes sandbox-status when hideSandboxStatus is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('sandbox-status');
|
||||
});
|
||||
|
||||
it('removes model-name, context-remaining, and quota when hideModelInfo is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('model-name');
|
||||
expect(items).not.toContain('context-remaining');
|
||||
expect(items).not.toContain('quota');
|
||||
});
|
||||
|
||||
it('includes context-remaining when hideContextPercentage is false', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: false } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('context-remaining');
|
||||
// Should be after model-name
|
||||
const modelIdx = items.indexOf('model-name');
|
||||
const contextIdx = items.indexOf('context-remaining');
|
||||
expect(contextIdx).toBe(modelIdx + 1);
|
||||
});
|
||||
|
||||
it('includes memory-usage when showMemoryUsage is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('memory-usage');
|
||||
});
|
||||
|
||||
it('handles combination of settings', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideModelInfo: true,
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'context-remaining',
|
||||
'memory-usage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
132
packages/cli/src/config/footerItems.ts
Normal file
132
packages/cli/src/config/footerItems.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { MergedSettings } from './settings.js';
|
||||
|
||||
export const ALL_ITEMS = [
|
||||
{
|
||||
id: 'cwd',
|
||||
header: 'workspace (/directory)',
|
||||
description: 'Current working directory',
|
||||
},
|
||||
{
|
||||
id: 'git-branch',
|
||||
header: 'branch',
|
||||
description: 'Current git branch name (not shown when unavailable)',
|
||||
},
|
||||
{
|
||||
id: 'sandbox-status',
|
||||
header: 'sandbox',
|
||||
description: 'Sandbox type and trust indicator',
|
||||
},
|
||||
{
|
||||
id: 'model-name',
|
||||
header: '/model',
|
||||
description: 'Current model identifier',
|
||||
},
|
||||
{
|
||||
id: 'context-remaining',
|
||||
header: 'context',
|
||||
description: 'Percentage of context window remaining',
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
header: '/stats',
|
||||
description: 'Remaining usage on daily limit (not shown when unavailable)',
|
||||
},
|
||||
{
|
||||
id: 'memory-usage',
|
||||
header: 'memory',
|
||||
description: 'Memory used by the application',
|
||||
},
|
||||
{
|
||||
id: 'session-id',
|
||||
header: 'session',
|
||||
description: 'Unique identifier for the current session',
|
||||
},
|
||||
{
|
||||
id: 'code-changes',
|
||||
header: 'diff',
|
||||
description: 'Lines added/removed in the session (not shown when zero)',
|
||||
},
|
||||
{
|
||||
id: 'token-count',
|
||||
header: 'tokens',
|
||||
description: 'Total tokens used in the session (not shown when zero)',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type FooterItemId = (typeof ALL_ITEMS)[number]['id'];
|
||||
|
||||
export const DEFAULT_ORDER = [
|
||||
'cwd',
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'model-name',
|
||||
'context-remaining',
|
||||
'quota',
|
||||
'memory-usage',
|
||||
'session-id',
|
||||
'code-changes',
|
||||
'token-count',
|
||||
];
|
||||
|
||||
export function deriveItemsFromLegacySettings(
|
||||
settings: MergedSettings,
|
||||
): string[] {
|
||||
const defaults = [
|
||||
'cwd',
|
||||
'git-branch',
|
||||
'sandbox-status',
|
||||
'model-name',
|
||||
'quota',
|
||||
];
|
||||
const items = [...defaults];
|
||||
|
||||
const remove = (arr: string[], id: string) => {
|
||||
const idx = arr.indexOf(id);
|
||||
if (idx !== -1) arr.splice(idx, 1);
|
||||
};
|
||||
|
||||
if (settings.ui.footer.hideCWD) remove(items, 'cwd');
|
||||
if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox-status');
|
||||
if (settings.ui.footer.hideModelInfo) {
|
||||
remove(items, 'model-name');
|
||||
remove(items, 'context-remaining');
|
||||
remove(items, 'quota');
|
||||
}
|
||||
if (
|
||||
!settings.ui.footer.hideContextPercentage &&
|
||||
!items.includes('context-remaining')
|
||||
) {
|
||||
const modelIdx = items.indexOf('model-name');
|
||||
if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-remaining');
|
||||
else items.push('context-remaining');
|
||||
}
|
||||
if (settings.ui.showMemoryUsage) items.push('memory-usage');
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const VALID_IDS: Set<string> = new Set(ALL_ITEMS.map((i) => i.id));
|
||||
|
||||
/**
|
||||
* Resolves the ordered list and selected set of footer items from settings.
|
||||
* Used by FooterConfigDialog to initialize and reset state.
|
||||
*/
|
||||
export function resolveFooterState(settings: MergedSettings): {
|
||||
orderedIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
} {
|
||||
const source = (
|
||||
settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings)
|
||||
).filter((id: string) => VALID_IDS.has(id));
|
||||
const others = DEFAULT_ORDER.filter((id) => !source.includes(id));
|
||||
return {
|
||||
orderedIds: [...source, ...others],
|
||||
selectedIds: new Set(source),
|
||||
};
|
||||
}
|
||||
@@ -571,14 +571,34 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Settings for the footer.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
label: 'Footer Items',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'List of item IDs to display in the footer. Rendered in order',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
showLabels: {
|
||||
type: 'boolean',
|
||||
label: 'Show Footer Labels',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Display a second line above the footer items with descriptive headers (e.g., /model).',
|
||||
showInDialog: false,
|
||||
},
|
||||
hideCWD: {
|
||||
type: 'boolean',
|
||||
label: 'Hide CWD',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the current working directory path in the footer.',
|
||||
description: 'Hide the current working directory in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideSandboxStatus: {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { footerCommand } from '../ui/commands/footerCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||
@@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
]
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
footerCommand,
|
||||
shortcutsCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
rewindCommand,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { vi } from 'vitest';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { act, useState } from 'react';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||
@@ -509,7 +510,22 @@ const configProxy = new Proxy({} as Config, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'getTargetDir') {
|
||||
return () =>
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
|
||||
path.join(
|
||||
path.parse(process.cwd()).root,
|
||||
'Users',
|
||||
'test',
|
||||
'project',
|
||||
'foo',
|
||||
'bar',
|
||||
'and',
|
||||
'some',
|
||||
'more',
|
||||
'directories',
|
||||
'to',
|
||||
'make',
|
||||
'it',
|
||||
'long',
|
||||
);
|
||||
}
|
||||
if (prop === 'getUseBackgroundColor') {
|
||||
return () => true;
|
||||
|
||||
25
packages/cli/src/ui/commands/footerCommand.tsx
Normal file
25
packages/cli/src/ui/commands/footerCommand.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type SlashCommand,
|
||||
type CommandContext,
|
||||
type OpenCustomDialogActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { FooterConfigDialog } from '../components/FooterConfigDialog.js';
|
||||
|
||||
export const footerCommand: SlashCommand = {
|
||||
name: 'footer',
|
||||
altNames: ['statusline'],
|
||||
description: 'Configure which items appear in the footer (statusline)',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context: CommandContext): OpenCustomDialogActionReturn => ({
|
||||
type: 'custom_dialog',
|
||||
component: <FooterConfigDialog onClose={context.ui.removeComponent} />,
|
||||
}),
|
||||
};
|
||||
@@ -28,7 +28,7 @@ vi.mock('../../config/settings.js', () => ({
|
||||
|
||||
describe('ContextUsageDisplay', () => {
|
||||
it('renders correct percentage left', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={5000}
|
||||
model="gemini-pro"
|
||||
@@ -37,12 +37,11 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('50% context left');
|
||||
unmount();
|
||||
expect(output).toContain('50% left');
|
||||
});
|
||||
|
||||
it('renders short label when terminal width is small', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={2000}
|
||||
model="gemini-pro"
|
||||
@@ -53,11 +52,10 @@ describe('ContextUsageDisplay', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('80%');
|
||||
expect(output).not.toContain('context left');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders 0% when full', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={10000}
|
||||
model="gemini-pro"
|
||||
@@ -66,7 +64,6 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0% context left');
|
||||
unmount();
|
||||
expect(output).toContain('0% left');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,18 +12,20 @@ export const ContextUsageDisplay = ({
|
||||
promptTokenCount,
|
||||
model,
|
||||
terminalWidth,
|
||||
color = theme.text.primary,
|
||||
}: {
|
||||
promptTokenCount: number;
|
||||
model: string;
|
||||
terminalWidth: number;
|
||||
color?: string;
|
||||
}) => {
|
||||
const percentage = getContextUsagePercentage(promptTokenCount, model);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
const label = terminalWidth < 100 ? '%' : '% left';
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
<Text color={color}>
|
||||
{percentageLeft}
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -4,46 +4,30 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
tildeifyPath,
|
||||
ToolCallDecision,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
shortenPath: (p: string, len: number) => {
|
||||
if (p.length > len) {
|
||||
return '...' + p.slice(p.length - len + 3);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
model: 'gemini-pro',
|
||||
targetDir:
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||
branchName: 'main',
|
||||
// Normalize paths to POSIX slashes for stable cross-platform snapshots.
|
||||
const normalizeFrame = (frame: string | undefined) => {
|
||||
if (!frame) return frame;
|
||||
return frame.replace(/\\/g, '/');
|
||||
};
|
||||
|
||||
const mockSessionStats: SessionStatsState = {
|
||||
sessionId: 'test-session',
|
||||
const mockSessionStats = {
|
||||
sessionId: 'test-session-id',
|
||||
sessionStartTime: new Date(),
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 0,
|
||||
lastPromptTokenCount: 150000,
|
||||
metrics: {
|
||||
models: {},
|
||||
files: {
|
||||
totalLinesAdded: 12,
|
||||
totalLinesRemoved: 4,
|
||||
},
|
||||
tools: {
|
||||
count: 0,
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
@@ -52,18 +36,47 @@ const mockSessionStats: SessionStatsState = {
|
||||
accept: 0,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
[ToolCallDecision.AUTO_ACCEPT]: 0,
|
||||
auto_accept: 0,
|
||||
},
|
||||
byName: {},
|
||||
latency: { avg: 0, max: 0, min: 0 },
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
models: {
|
||||
'gemini-pro': {
|
||||
api: {
|
||||
totalRequests: 0,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 0,
|
||||
},
|
||||
tokens: {
|
||||
input: 0,
|
||||
prompt: 0,
|
||||
candidates: 0,
|
||||
total: 1500,
|
||||
cached: 0,
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
model: 'gemini-pro',
|
||||
targetDir: '/long/path/to/some/deeply/nested/directories/to/make/it/long',
|
||||
debugMode: false,
|
||||
branchName: 'main',
|
||||
errorCount: 0,
|
||||
};
|
||||
|
||||
describe('<Footer />', () => {
|
||||
beforeEach(() => {
|
||||
const root = path.parse(process.cwd()).root;
|
||||
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
|
||||
});
|
||||
|
||||
it('renders the component', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
@@ -90,11 +103,12 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const pathLength = Math.max(20, Math.floor(79 * 0.25));
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - pathLength + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
// Should contain some part of the path, likely shortened
|
||||
expect(output).toContain(
|
||||
path.join('directories', 'to', 'make', 'it', 'long'),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -107,10 +121,11 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
expect(output).toContain(
|
||||
path.join('directories', 'to', 'make', 'it', 'long'),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -127,7 +142,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
||||
expect(lastFrame()).toContain(defaultProps.branchName);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -140,7 +155,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
||||
expect(lastFrame()).not.toContain('Branch');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -149,7 +164,13 @@ describe('<Footer />', () => {
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
uiState: {
|
||||
currentModel: defaultProps.model,
|
||||
sessionStats: {
|
||||
...mockSessionStats,
|
||||
lastPromptTokenCount: 1000,
|
||||
},
|
||||
},
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
@@ -161,7 +182,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% left/);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -189,7 +210,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('15%');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -217,7 +238,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Usage remaining');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -244,8 +265,8 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Limit reached');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()?.toLowerCase()).toContain('limit reached');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -377,7 +398,9 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||
'complete-footer-wide',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -399,7 +422,9 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toMatchSnapshot('footer-minimal');
|
||||
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
|
||||
'footer-minimal',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -421,7 +446,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot('footer-no-model');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -443,7 +468,9 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||
'footer-only-sandbox',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -464,7 +491,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).not.toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).not.toMatch(/\d+% left/);
|
||||
unmount();
|
||||
});
|
||||
it('shows the context percentage when hideContextPercentage is false', async () => {
|
||||
@@ -484,7 +511,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% left/);
|
||||
unmount();
|
||||
});
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
|
||||
@@ -503,115 +530,232 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||
'complete-footer-narrow',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error summary visibility', () => {
|
||||
it('hides error summary in low verbosity mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 2,
|
||||
showErrorDetails: false,
|
||||
describe('Footer Token Formatting', () => {
|
||||
const renderWithTokens = async (tokens: number) => {
|
||||
const result = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: {
|
||||
...mockSessionStats,
|
||||
metrics: {
|
||||
...mockSessionStats.metrics,
|
||||
models: {
|
||||
'gemini-pro': {
|
||||
api: {
|
||||
totalRequests: 0,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 0,
|
||||
},
|
||||
tokens: {
|
||||
input: 0,
|
||||
prompt: 0,
|
||||
candidates: 0,
|
||||
total: tokens,
|
||||
cached: 0,
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'low' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('F12 for details');
|
||||
expect(lastFrame()).not.toContain('2 errors');
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: ['token-count'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await result.waitUntilReady();
|
||||
return result;
|
||||
};
|
||||
|
||||
it('formats thousands with k', async () => {
|
||||
const { lastFrame, unmount } = await renderWithTokens(1500);
|
||||
expect(lastFrame()).toContain('1.5k tokens');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows error summary in full verbosity mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 2,
|
||||
showErrorDetails: false,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'full' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('F12 for details');
|
||||
expect(lastFrame()).toContain('2 errors');
|
||||
it('formats millions with m', async () => {
|
||||
const { lastFrame, unmount } = await renderWithTokens(1500000);
|
||||
expect(lastFrame()).toContain('1.5m tokens');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows error summary in debug mode even when verbosity is low', async () => {
|
||||
const debugConfig = makeFakeConfig();
|
||||
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
|
||||
it('formats billions with b', async () => {
|
||||
const { lastFrame, unmount } = await renderWithTokens(1500000000);
|
||||
expect(lastFrame()).toContain('1.5b tokens');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('formats small numbers without suffix', async () => {
|
||||
const { lastFrame, unmount } = await renderWithTokens(500);
|
||||
expect(lastFrame()).toContain('500 tokens');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer Custom Items', () => {
|
||||
it('renders items in the specified order', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
config: debugConfig,
|
||||
uiState: {
|
||||
currentModel: 'gemini-pro',
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 1,
|
||||
showErrorDetails: false,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'low' } },
|
||||
ui: {
|
||||
footer: {
|
||||
items: ['model-name', 'cwd'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('F12 for details');
|
||||
expect(lastFrame()).toContain('1 error');
|
||||
|
||||
const output = lastFrame();
|
||||
const modelIdx = output.indexOf('/model');
|
||||
const cwdIdx = output.indexOf('workspace (/directory)');
|
||||
expect(modelIdx).toBeLessThan(cwdIdx);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders multiple items with proper alignment', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
branchName: 'main',
|
||||
},
|
||||
settings: createMockSettings({
|
||||
vimMode: {
|
||||
vimMode: true,
|
||||
},
|
||||
ui: {
|
||||
footer: {
|
||||
items: ['cwd', 'git-branch', 'sandbox-status', 'model-name'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
// Headers should be present
|
||||
expect(output).toContain('workspace (/directory)');
|
||||
expect(output).toContain('branch');
|
||||
expect(output).toContain('sandbox');
|
||||
expect(output).toContain('/model');
|
||||
// Data should be present
|
||||
expect(output).toContain('main');
|
||||
expect(output).toContain('gemini-pro');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles empty items array', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
expect(output).toBeDefined();
|
||||
expect(output.trim()).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not render items that are conditionally hidden', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
branchName: undefined, // No branch
|
||||
},
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: ['cwd', 'git-branch', 'model-name'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
expect(output).not.toContain('branch');
|
||||
expect(output).toContain('workspace (/directory)');
|
||||
expect(output).toContain('/model');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback mode display', () => {
|
||||
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Footer should show the effective model (Flash), not the config model (Pro)
|
||||
expect(lastFrame()).toContain('gemini-2.5-flash');
|
||||
expect(lastFrame()).not.toContain('gemini-2.5-pro');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display Pro model when NOT in fallback mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('gemini-2.5-pro');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback mode display', () => {
|
||||
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Footer should show the effective model (Flash), not the config model (Pro)
|
||||
expect(lastFrame()).toContain('gemini-2.5-flash');
|
||||
expect(lastFrame()).not.toContain('gemini-2.5-pro');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display Pro model when NOT in fallback mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('gemini-2.5-pro');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
getDisplayString,
|
||||
checkExhaustive,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
@@ -18,11 +19,142 @@ import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { isDevelopment } from '../../utils/installationInfo.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import {
|
||||
ALL_ITEMS,
|
||||
type FooterItemId,
|
||||
deriveItemsFromLegacySettings,
|
||||
} from '../../config/footerItems.js';
|
||||
|
||||
interface CwdIndicatorProps {
|
||||
targetDir: string;
|
||||
maxWidth: number;
|
||||
debugMode?: boolean;
|
||||
debugMessage?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const CwdIndicator: React.FC<CwdIndicatorProps> = ({
|
||||
targetDir,
|
||||
maxWidth,
|
||||
debugMode,
|
||||
debugMessage,
|
||||
color = theme.text.primary,
|
||||
}) => {
|
||||
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
|
||||
const availableForPath = Math.max(10, maxWidth - debugSuffix.length);
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath);
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{displayPath}
|
||||
{debugMode && <Text color={theme.status.error}>{debugSuffix}</Text>}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface SandboxIndicatorProps {
|
||||
isTrustedFolder: boolean | undefined;
|
||||
}
|
||||
|
||||
const SandboxIndicator: React.FC<SandboxIndicatorProps> = ({
|
||||
isTrustedFolder,
|
||||
}) => {
|
||||
if (isTrustedFolder === false) {
|
||||
return <Text color={theme.status.warning}>untrusted</Text>;
|
||||
}
|
||||
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
if (sandbox && sandbox !== 'sandbox-exec') {
|
||||
return (
|
||||
<Text color="green">{sandbox.replace(/^gemini-(?:cli-)?/, '')}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (sandbox === 'sandbox-exec') {
|
||||
return (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.ui.comment}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text color={theme.status.error}>no sandbox</Text>;
|
||||
};
|
||||
|
||||
const CorgiIndicator: React.FC = () => (
|
||||
<Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
<Text color={theme.text.primary}>`)</Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
export interface FooterRowItem {
|
||||
key: string;
|
||||
header: string;
|
||||
element: React.ReactNode;
|
||||
}
|
||||
|
||||
const COLUMN_GAP = 3;
|
||||
|
||||
export const FooterRow: React.FC<{
|
||||
items: FooterRowItem[];
|
||||
showLabels: boolean;
|
||||
}> = ({ items, showLabels }) => {
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
if (idx > 0 && !showLabels) {
|
||||
elements.push(
|
||||
<Box key={`sep-${item.key}`}>
|
||||
<Text color={theme.ui.comment}> · </Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<Box key={item.key} flexDirection="column">
|
||||
{showLabels && (
|
||||
<Box>
|
||||
<Text color={theme.ui.comment}>{item.header}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box>{item.element}</Box>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
columnGap={showLabels ? COLUMN_GAP : 0}
|
||||
>
|
||||
{elements}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function isFooterItemId(id: string): id is FooterItemId {
|
||||
return ALL_ITEMS.some((i) => i.id === id);
|
||||
}
|
||||
|
||||
interface FooterColumn {
|
||||
id: string;
|
||||
header: string;
|
||||
element: (maxWidth: number) => React.ReactNode;
|
||||
width: number;
|
||||
isHighPriority: boolean;
|
||||
}
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
@@ -58,142 +190,237 @@ export const Footer: React.FC = () => {
|
||||
quotaStats: uiState.quota.stats,
|
||||
};
|
||||
|
||||
const showMemoryUsage =
|
||||
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
|
||||
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
|
||||
const showErrorSummary =
|
||||
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
|
||||
const hideCWD = settings.merged.ui.footer.hideCWD;
|
||||
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
|
||||
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
|
||||
const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
|
||||
|
||||
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
|
||||
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
||||
const displayVimMode = vimEnabled ? vimMode : undefined;
|
||||
const items =
|
||||
settings.merged.ui.footer.items ??
|
||||
deriveItemsFromLegacySettings(settings.merged);
|
||||
const showLabels = settings.merged.ui.footer.showLabels !== false;
|
||||
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
|
||||
|
||||
const showDebugProfiler = debugMode || isDevelopment;
|
||||
const potentialColumns: FooterColumn[] = [];
|
||||
|
||||
const addCol = (
|
||||
id: string,
|
||||
header: string,
|
||||
element: (maxWidth: number) => React.ReactNode,
|
||||
dataWidth: number,
|
||||
isHighPriority = false,
|
||||
) => {
|
||||
potentialColumns.push({
|
||||
id,
|
||||
header: showLabels ? header : '',
|
||||
element,
|
||||
width: Math.max(dataWidth, showLabels ? header.length : 0),
|
||||
isHighPriority,
|
||||
});
|
||||
};
|
||||
|
||||
// 1. System Indicators (Far Left, high priority)
|
||||
if (uiState.showDebugProfiler) {
|
||||
addCol('debug', '', () => <DebugProfiler />, 45, true);
|
||||
}
|
||||
if (displayVimMode) {
|
||||
const vimStr = `[${displayVimMode}]`;
|
||||
addCol(
|
||||
'vim',
|
||||
'',
|
||||
() => <Text color={theme.text.accent}>{vimStr}</Text>,
|
||||
vimStr.length,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Main Configurable Items
|
||||
for (const id of items) {
|
||||
if (!isFooterItemId(id)) continue;
|
||||
const itemConfig = ALL_ITEMS.find((i) => i.id === id);
|
||||
const header = itemConfig?.header ?? id;
|
||||
|
||||
switch (id) {
|
||||
case 'cwd': {
|
||||
const fullPath = tildeifyPath(targetDir);
|
||||
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
(maxWidth) => (
|
||||
<CwdIndicator
|
||||
targetDir={targetDir}
|
||||
maxWidth={maxWidth}
|
||||
debugMode={debugMode}
|
||||
debugMessage={debugMessage}
|
||||
color={itemColor}
|
||||
/>
|
||||
),
|
||||
fullPath.length + debugSuffix.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'git-branch': {
|
||||
if (branchName) {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{branchName}</Text>,
|
||||
branchName.length,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sandbox-status': {
|
||||
let str = 'no sandbox';
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
if (isTrustedFolder === false) str = 'untrusted';
|
||||
else if (sandbox === 'sandbox-exec')
|
||||
str = `macOS Seatbelt (${process.env['SEATBELT_PROFILE']})`;
|
||||
else if (sandbox) str = sandbox.replace(/^gemini-(?:cli-)?/, '');
|
||||
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <SandboxIndicator isTrustedFolder={isTrustedFolder} />,
|
||||
str.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'model-name': {
|
||||
const str = getDisplayString(model);
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{str}</Text>,
|
||||
str.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'context-remaining': {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
color={itemColor}
|
||||
/>
|
||||
),
|
||||
10, // "100% left" is 9 chars
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'quota': {
|
||||
if (quotaStats?.remaining !== undefined && quotaStats.limit) {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
forceShow={true}
|
||||
lowercase={true}
|
||||
/>
|
||||
),
|
||||
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'memory-usage': {
|
||||
addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
|
||||
break;
|
||||
}
|
||||
case 'session-id': {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<Text color={itemColor}>
|
||||
{uiState.sessionStats.sessionId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
8,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'code-changes': {
|
||||
const added = uiState.sessionStats.metrics.files.totalLinesAdded;
|
||||
const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
|
||||
if (added > 0 || removed > 0) {
|
||||
const str = `+${added} -${removed}`;
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<Text>
|
||||
<Text color={theme.status.success}>+{added}</Text>{' '}
|
||||
<Text color={theme.status.error}>-{removed}</Text>
|
||||
</Text>
|
||||
),
|
||||
str.length,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'token-count': {
|
||||
let total = 0;
|
||||
for (const m of Object.values(uiState.sessionStats.metrics.models))
|
||||
total += m.tokens.total;
|
||||
if (total > 0) {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const formatted = formatter.format(total).toLowerCase();
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{formatted} tokens</Text>,
|
||||
formatted.length + 7,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
checkExhaustive(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Transients
|
||||
if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
|
||||
if (!showErrorDetails && errorCount > 0) {
|
||||
addCol(
|
||||
'error-count',
|
||||
'',
|
||||
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
|
||||
12,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Width Fitting Logic ---
|
||||
// Sum up fixed widths to see how much is left for CWD
|
||||
const nonCwdWidth = potentialColumns
|
||||
.filter((c) => c.id !== 'cwd')
|
||||
.reduce((sum, c) => sum + c.width + (showLabels ? COLUMN_GAP : 3), 0);
|
||||
const cwdBudget = Math.max(20, terminalWidth - nonCwdWidth - 4);
|
||||
|
||||
const rowItems: FooterRowItem[] = potentialColumns.map((col) => {
|
||||
const maxWidth = col.id === 'cwd' ? cwdBudget : col.width;
|
||||
return {
|
||||
key: col.id,
|
||||
header: col.header,
|
||||
element: col.element(maxWidth),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
justifyContent={justifyContent}
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
paddingX={1}
|
||||
paddingBottom={0}
|
||||
marginBottom={0}
|
||||
>
|
||||
{(showDebugProfiler || displayVimMode || !hideCWD) && (
|
||||
<Box>
|
||||
{showDebugProfiler && <DebugProfiler />}
|
||||
{displayVimMode && (
|
||||
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||
)}
|
||||
{!hideCWD && (
|
||||
<Text color={theme.text.primary}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Middle Section: Centered Trust/Sandbox Info */}
|
||||
{!hideSandboxStatus && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
display="flex"
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
|
||||
</Text>
|
||||
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox
|
||||
{terminalWidth >= 100 && (
|
||||
<Text color={theme.text.secondary}> (see /docs)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
{!hideModelInfo && (
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.text.secondary}>/model </Text>
|
||||
{getDisplayString(model)}
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{quotaStats && (
|
||||
<>
|
||||
{' '}
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
<Box alignItems="center">
|
||||
{corgiMode && (
|
||||
<Box paddingLeft={1} flexDirection="row">
|
||||
<Text>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
<Text color={theme.text.primary}>`)</Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showErrorSummary && (
|
||||
<Box paddingLeft={1} flexDirection="row">
|
||||
<Text color={theme.ui.comment}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="wrap">
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
153
packages/cli/src/ui/components/FooterConfigDialog.test.tsx
Normal file
153
packages/cli/src/ui/components/FooterConfigDialog.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { FooterConfigDialog } from './FooterConfigDialog.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { act } from 'react';
|
||||
|
||||
describe('<FooterConfigDialog />', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly with default settings', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('toggles an item when enter is pressed', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
act(() => {
|
||||
stdin.write('\r'); // Enter to toggle
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[ ] workspace (/directory)');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[✓] workspace (/directory)');
|
||||
});
|
||||
});
|
||||
|
||||
it('reorders items with arrow keys', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
// Initial order: workspace (/directory), branch, ...
|
||||
const output = lastFrame();
|
||||
const cwdIdx = output.indexOf('] workspace (/directory)');
|
||||
const branchIdx = output.indexOf('] branch');
|
||||
expect(cwdIdx).toBeGreaterThan(-1);
|
||||
expect(branchIdx).toBeGreaterThan(-1);
|
||||
expect(cwdIdx).toBeLessThan(branchIdx);
|
||||
|
||||
// Move workspace (/directory) down (right arrow)
|
||||
act(() => {
|
||||
stdin.write('\u001b[C'); // Right arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const outputAfter = lastFrame();
|
||||
const cwdIdxAfter = outputAfter.indexOf('] workspace (/directory)');
|
||||
const branchIdxAfter = outputAfter.indexOf('] branch');
|
||||
expect(cwdIdxAfter).toBeGreaterThan(-1);
|
||||
expect(branchIdxAfter).toBeGreaterThan(-1);
|
||||
expect(branchIdxAfter).toBeLessThan(cwdIdxAfter);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on Esc', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
act(() => {
|
||||
stdin.write('\x1b'); // Esc
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('highlights the active item in the preview', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('~/project/path');
|
||||
|
||||
// Move focus down to 'git-branch'
|
||||
act(() => {
|
||||
stdin.write('\u001b[B'); // Down arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('main');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an empty preview when all items are deselected', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
act(() => {
|
||||
stdin.write('\r'); // Toggle (deselect)
|
||||
stdin.write('\u001b[B'); // Down arrow
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Preview:');
|
||||
expect(output).not.toContain('~/project/path');
|
||||
expect(output).not.toContain('docker');
|
||||
expect(output).not.toContain('gemini-2.5-pro');
|
||||
expect(output).not.toContain('1.2k left');
|
||||
});
|
||||
});
|
||||
});
|
||||
406
packages/cli/src/ui/components/FooterConfigDialog.tsx
Normal file
406
packages/cli/src/ui/components/FooterConfigDialog.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useReducer } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useSettingsStore } from '../contexts/SettingsContext.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { FooterRow, type FooterRowItem } from './Footer.js';
|
||||
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
interface FooterConfigDialogProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface FooterConfigState {
|
||||
orderedIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
activeIndex: number;
|
||||
scrollOffset: number;
|
||||
}
|
||||
|
||||
type FooterConfigAction =
|
||||
| { type: 'MOVE_UP'; itemCount: number; maxToShow: number }
|
||||
| { type: 'MOVE_DOWN'; itemCount: number; maxToShow: number }
|
||||
| {
|
||||
type: 'MOVE_LEFT';
|
||||
items: Array<{ key: string }>;
|
||||
}
|
||||
| {
|
||||
type: 'MOVE_RIGHT';
|
||||
items: Array<{ key: string }>;
|
||||
}
|
||||
| { type: 'TOGGLE_ITEM'; items: Array<{ key: string }> }
|
||||
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> }
|
||||
| { type: 'RESET_INDEX' };
|
||||
|
||||
function footerConfigReducer(
|
||||
state: FooterConfigState,
|
||||
action: FooterConfigAction,
|
||||
): FooterConfigState {
|
||||
switch (action.type) {
|
||||
case 'MOVE_UP': {
|
||||
const { itemCount, maxToShow } = action;
|
||||
const totalSlots = itemCount + 2; // +1 for showLabels, +1 for reset
|
||||
const newIndex =
|
||||
state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
|
||||
let newOffset = state.scrollOffset;
|
||||
|
||||
if (newIndex < itemCount) {
|
||||
if (newIndex === itemCount - 1) {
|
||||
newOffset = Math.max(0, itemCount - maxToShow);
|
||||
} else if (newIndex < state.scrollOffset) {
|
||||
newOffset = newIndex;
|
||||
}
|
||||
}
|
||||
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
|
||||
}
|
||||
case 'MOVE_DOWN': {
|
||||
const { itemCount, maxToShow } = action;
|
||||
const totalSlots = itemCount + 2;
|
||||
const newIndex =
|
||||
state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
|
||||
let newOffset = state.scrollOffset;
|
||||
|
||||
if (newIndex === 0) {
|
||||
newOffset = 0;
|
||||
} else if (
|
||||
newIndex < itemCount &&
|
||||
newIndex >= state.scrollOffset + maxToShow
|
||||
) {
|
||||
newOffset = newIndex - maxToShow + 1;
|
||||
}
|
||||
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
|
||||
}
|
||||
case 'MOVE_LEFT':
|
||||
case 'MOVE_RIGHT': {
|
||||
const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
|
||||
const currentItem = action.items[state.activeIndex];
|
||||
if (!currentItem) return state;
|
||||
|
||||
const currentId = currentItem.key;
|
||||
const currentIndex = state.orderedIds.indexOf(currentId);
|
||||
const newIndex = currentIndex + direction;
|
||||
|
||||
if (newIndex < 0 || newIndex >= state.orderedIds.length) return state;
|
||||
|
||||
const newOrderedIds = [...state.orderedIds];
|
||||
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
|
||||
newOrderedIds[newIndex],
|
||||
newOrderedIds[currentIndex],
|
||||
];
|
||||
|
||||
return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
|
||||
}
|
||||
case 'TOGGLE_ITEM': {
|
||||
const isSystemFocused = state.activeIndex >= action.items.length;
|
||||
if (isSystemFocused) return state;
|
||||
|
||||
const item = action.items[state.activeIndex];
|
||||
if (!item) return state;
|
||||
|
||||
const nextSelected = new Set(state.selectedIds);
|
||||
if (nextSelected.has(item.key)) {
|
||||
nextSelected.delete(item.key);
|
||||
} else {
|
||||
nextSelected.add(item.key);
|
||||
}
|
||||
return { ...state, selectedIds: nextSelected };
|
||||
}
|
||||
case 'SET_STATE':
|
||||
return { ...state, ...action.payload };
|
||||
case 'RESET_INDEX':
|
||||
return { ...state, activeIndex: 0, scrollOffset: 0 };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
const maxItemsToShow = 10;
|
||||
|
||||
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
|
||||
...resolveFooterState(settings.merged),
|
||||
activeIndex: 0,
|
||||
scrollOffset: 0,
|
||||
}));
|
||||
|
||||
const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
|
||||
|
||||
// Prepare items
|
||||
const listItems = useMemo(
|
||||
() =>
|
||||
orderedIds
|
||||
.map((id: string) => {
|
||||
const item = ALL_ITEMS.find((i) => i.id === id);
|
||||
if (!item) return null;
|
||||
return {
|
||||
key: id,
|
||||
label: item.header,
|
||||
description: item.description as string,
|
||||
};
|
||||
})
|
||||
.filter((i): i is NonNullable<typeof i> => i !== null),
|
||||
[orderedIds],
|
||||
);
|
||||
|
||||
const maxLabelWidth = useMemo(
|
||||
() => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
|
||||
[listItems],
|
||||
);
|
||||
|
||||
const isResetFocused = activeIndex === listItems.length + 1;
|
||||
const isShowLabelsFocused = activeIndex === listItems.length;
|
||||
|
||||
const handleSaveAndClose = useCallback(() => {
|
||||
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
|
||||
const currentSetting = settings.merged.ui?.footer?.items;
|
||||
if (JSON.stringify(finalItems) !== JSON.stringify(currentSetting)) {
|
||||
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
|
||||
}
|
||||
onClose?.();
|
||||
}, [
|
||||
orderedIds,
|
||||
selectedIds,
|
||||
setSetting,
|
||||
settings.merged.ui?.footer?.items,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
const handleResetToDefaults = useCallback(() => {
|
||||
setSetting(SettingScope.User, 'ui.footer.items', undefined);
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...resolveFooterState(settings.merged),
|
||||
activeIndex: 0,
|
||||
scrollOffset: 0,
|
||||
},
|
||||
});
|
||||
}, [setSetting, settings.merged]);
|
||||
|
||||
const handleToggleLabels = useCallback(() => {
|
||||
const current = settings.merged.ui.footer.showLabels !== false;
|
||||
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
|
||||
}, [setSetting, settings.merged.ui.footer.showLabels]);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleSaveAndClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
dispatch({
|
||||
type: 'MOVE_UP',
|
||||
itemCount: listItems.length,
|
||||
maxToShow: maxItemsToShow,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
dispatch({
|
||||
type: 'MOVE_DOWN',
|
||||
itemCount: listItems.length,
|
||||
maxToShow: maxItemsToShow,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
dispatch({ type: 'MOVE_LEFT', items: listItems });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
dispatch({ type: 'MOVE_RIGHT', items: listItems });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.RETURN](key) || key.name === 'space') {
|
||||
if (isResetFocused) {
|
||||
handleResetToDefaults();
|
||||
} else if (isShowLabelsFocused) {
|
||||
handleToggleLabels();
|
||||
} else {
|
||||
dispatch({ type: 'TOGGLE_ITEM', items: listItems });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
const visibleItems = listItems.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxItemsToShow,
|
||||
);
|
||||
|
||||
const activeId = listItems[activeIndex]?.key;
|
||||
const showLabels = settings.merged.ui.footer.showLabels !== false;
|
||||
|
||||
// Preview logic
|
||||
const previewContent = useMemo(() => {
|
||||
if (isResetFocused) {
|
||||
return (
|
||||
<Text color={theme.ui.comment} italic>
|
||||
Default footer (uses legacy settings)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const itemsToPreview = orderedIds.filter((id: string) =>
|
||||
selectedIds.has(id),
|
||||
);
|
||||
if (itemsToPreview.length === 0) return null;
|
||||
|
||||
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
|
||||
const getColor = (id: string, defaultColor?: string) =>
|
||||
id === activeId ? 'white' : defaultColor || itemColor;
|
||||
|
||||
// Mock data for preview (headers come from ALL_ITEMS)
|
||||
const mockData: Record<string, React.ReactNode> = {
|
||||
cwd: <Text color={getColor('cwd', itemColor)}>~/project/path</Text>,
|
||||
'git-branch': <Text color={getColor('git-branch', itemColor)}>main</Text>,
|
||||
'sandbox-status': (
|
||||
<Text color={getColor('sandbox-status', 'green')}>docker</Text>
|
||||
),
|
||||
'model-name': (
|
||||
<Text color={getColor('model-name', itemColor)}>gemini-2.5-pro</Text>
|
||||
),
|
||||
'context-remaining': (
|
||||
<Text color={getColor('context-remaining', itemColor)}>85% left</Text>
|
||||
),
|
||||
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
|
||||
'memory-usage': (
|
||||
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
|
||||
),
|
||||
'session-id': (
|
||||
<Text color={getColor('session-id', itemColor)}>769992f9</Text>
|
||||
),
|
||||
'code-changes': (
|
||||
<Box flexDirection="row">
|
||||
<Text color={getColor('code-changes', theme.status.success)}>
|
||||
+12
|
||||
</Text>
|
||||
<Text color={getColor('code-changes')}> </Text>
|
||||
<Text color={getColor('code-changes', theme.status.error)}>-4</Text>
|
||||
</Box>
|
||||
),
|
||||
'token-count': (
|
||||
<Text color={getColor('token-count', itemColor)}>1.5k tokens</Text>
|
||||
),
|
||||
};
|
||||
|
||||
const rowItems: FooterRowItem[] = itemsToPreview
|
||||
.filter((id: string) => mockData[id])
|
||||
.map((id: string) => ({
|
||||
key: id,
|
||||
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
|
||||
element: mockData[id],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box overflow="hidden" flexWrap="nowrap">
|
||||
<Box flexShrink={0}>
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Configure Footer{'\n'}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Select which items to display in the footer.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1} minHeight={maxItemsToShow}>
|
||||
{visibleItems.length === 0 ? (
|
||||
<Text color={theme.text.secondary}>No items found.</Text>
|
||||
) : (
|
||||
visibleItems.map((item, idx) => {
|
||||
const index = scrollOffset + idx;
|
||||
const isFocused = index === activeIndex;
|
||||
const isChecked = selectedIds.has(item.key);
|
||||
|
||||
return (
|
||||
<Box key={item.key} flexDirection="row">
|
||||
<Text color={isFocused ? theme.status.success : undefined}>
|
||||
{isFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text
|
||||
color={isFocused ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
[{isChecked ? '✓' : ' '}]{' '}
|
||||
{item.label.padEnd(maxLabelWidth + 1)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> {item.description}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
|
||||
{isShowLabelsFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
|
||||
[{showLabels ? '✓' : ' '}] Show footer labels
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={isResetFocused ? theme.status.warning : undefined}>
|
||||
{isResetFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text
|
||||
color={isResetFocused ? theme.status.warning : theme.text.secondary}
|
||||
>
|
||||
Reset to default footer
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
↑/↓ navigate · ←/→ reorder · enter/space select · esc close
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text bold>Preview:</Text>
|
||||
<Box flexDirection="row">{previewContent}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,35 +6,32 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import process from 'node:process';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
|
||||
export const MemoryUsageDisplay: React.FC = () => {
|
||||
export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
|
||||
color = theme.text.primary,
|
||||
}) => {
|
||||
const [memoryUsage, setMemoryUsage] = useState<string>('');
|
||||
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(
|
||||
theme.text.secondary,
|
||||
);
|
||||
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
|
||||
|
||||
useEffect(() => {
|
||||
const updateMemory = () => {
|
||||
const usage = process.memoryUsage().rss;
|
||||
setMemoryUsage(formatBytes(usage));
|
||||
setMemoryUsageColor(
|
||||
usage >= 2 * 1024 * 1024 * 1024
|
||||
? theme.status.error
|
||||
: theme.text.secondary,
|
||||
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
|
||||
);
|
||||
};
|
||||
const intervalId = setInterval(updateMemory, 2000);
|
||||
updateMemory(); // Initial update
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
}, [color]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}> | </Text>
|
||||
<Text color={memoryUsageColor}>{memoryUsage}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ interface QuotaDisplayProps {
|
||||
limit: number | undefined;
|
||||
resetTime?: string;
|
||||
terse?: boolean;
|
||||
forceShow?: boolean;
|
||||
lowercase?: boolean;
|
||||
}
|
||||
|
||||
export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
@@ -25,6 +27,8 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
limit,
|
||||
resetTime,
|
||||
terse = false,
|
||||
forceShow = false,
|
||||
lowercase = false,
|
||||
}) => {
|
||||
if (remaining === undefined || limit === undefined || limit === 0) {
|
||||
return null;
|
||||
@@ -32,7 +36,7 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
|
||||
const percentage = (remaining / limit) * 100;
|
||||
|
||||
if (percentage > QUOTA_THRESHOLD_HIGH) {
|
||||
if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,20 +49,17 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
|
||||
|
||||
if (remaining === 0) {
|
||||
return (
|
||||
<Text color={color}>
|
||||
{terse
|
||||
? 'Limit reached'
|
||||
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
|
||||
</Text>
|
||||
);
|
||||
let text = terse
|
||||
? 'Limit reached'
|
||||
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
|
||||
if (lowercase) text = text.toLowerCase();
|
||||
return <Text color={color}>{text}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{terse
|
||||
? `${percentage.toFixed(0)}%`
|
||||
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
|
||||
</Text>
|
||||
);
|
||||
let text = terse
|
||||
? `${percentage.toFixed(0)}%`
|
||||
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
|
||||
if (lowercase) text = text.toLowerCase();
|
||||
|
||||
return <Text color={color}>{text}</Text>;
|
||||
};
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
|
||||
" ...s/to/make/it/long no sandbox /model gemini-pro 100%
|
||||
" workspace (/directory) sandbox /model context
|
||||
~/.../directories/to/make/it/long no sandbox gemini-pro 86%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left
|
||||
" workspace (/directory) sandbox /model context
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 86% left
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
|
||||
" no sandbox (see /docs)
|
||||
" sandbox
|
||||
no sandbox
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
|
||||
" ...directories/to/make/it/long no sandbox (see /docs)
|
||||
" workspace (/directory) sandbox
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
|
||||
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Configure Footer │
|
||||
│ │
|
||||
│ Select which items to display in the footer. │
|
||||
│ │
|
||||
│ > [✓] workspace (/directory) Current working directory │
|
||||
│ [✓] branch Current git branch name (not shown when unavailable) │
|
||||
│ [✓] sandbox Sandbox type and trust indicator │
|
||||
│ [✓] /model Current model identifier │
|
||||
│ [✓] /stats Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context Percentage of context window remaining │
|
||||
│ [ ] memory Memory used by the application │
|
||||
│ [ ] session Unique identifier for the current session │
|
||||
│ [ ] diff Lines added/removed in the session (not shown when zero) │
|
||||
│ [ ] tokens Total tokens used in the session (not shown when zero) │
|
||||
│ │
|
||||
│ [✓] Show footer labels │
|
||||
│ Reset to default footer │
|
||||
│ │
|
||||
│ ↑/↓ navigate · ←/→ reorder · enter/space select · esc close │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -327,10 +327,26 @@
|
||||
"default": {},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"title": "Footer Items",
|
||||
"description": "List of item IDs to display in the footer. Rendered in order",
|
||||
"markdownDescription": "List of item IDs to display in the footer. Rendered in order\n\n- Category: `UI`\n- Requires restart: `no`",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"showLabels": {
|
||||
"title": "Show Footer Labels",
|
||||
"description": "Display a second line above the footer items with descriptive headers (e.g., /model).",
|
||||
"markdownDescription": "Display a second line above the footer items with descriptive headers (e.g., /model).\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideCWD": {
|
||||
"title": "Hide CWD",
|
||||
"description": "Hide the current working directory path in the footer.",
|
||||
"markdownDescription": "Hide the current working directory path in the footer.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"description": "Hide the current working directory in the footer.",
|
||||
"markdownDescription": "Hide the current working directory in the footer.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user