From 9dc6898d28a42e1f209b83dc872c5dfa7b431d40 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 4 Mar 2026 21:21:48 -0500 Subject: [PATCH] feat: add custom footer configuration via `/footer` (#19001) Co-authored-by: Keith Guerin Co-authored-by: Jacob Richman --- docs/cli/settings.md | 2 +- docs/reference/configuration.md | 12 +- packages/cli/src/config/footerItems.test.ts | 91 +++ packages/cli/src/config/footerItems.ts | 132 +++++ packages/cli/src/config/settingsSchema.ts | 24 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/test-utils/render.tsx | 18 +- .../cli/src/ui/commands/footerCommand.tsx | 25 + .../components/ContextUsageDisplay.test.tsx | 8 +- .../src/ui/components/ContextUsageDisplay.tsx | 2 +- .../cli/src/ui/components/Footer.test.tsx | 380 ++++++++++--- packages/cli/src/ui/components/Footer.tsx | 519 +++++++++++++----- .../ui/components/FooterConfigDialog.test.tsx | 153 ++++++ .../src/ui/components/FooterConfigDialog.tsx | 406 ++++++++++++++ .../src/ui/components/MemoryUsageDisplay.tsx | 17 +- .../cli/src/ui/components/QuotaDisplay.tsx | 31 +- .../__snapshots__/Footer.test.tsx.snap | 21 +- .../FooterConfigDialog.test.tsx.snap | 34 ++ schemas/settings.schema.json | 20 +- 19 files changed, 1635 insertions(+), 262 deletions(-) create mode 100644 packages/cli/src/config/footerItems.test.ts create mode 100644 packages/cli/src/config/footerItems.ts create mode 100644 packages/cli/src/ui/commands/footerCommand.tsx create mode 100644 packages/cli/src/ui/components/FooterConfigDialog.test.tsx create mode 100644 packages/cli/src/ui/components/FooterConfigDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 37508fc04e..d2680d65ad 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -57,7 +57,7 @@ they appear in the UI. | 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 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 usage percentage. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9da687a3df..1f1299072b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -250,8 +250,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): diff --git a/packages/cli/src/config/footerItems.test.ts b/packages/cli/src/config/footerItems.test.ts new file mode 100644 index 0000000000..420246811b --- /dev/null +++ b/packages/cli/src/config/footerItems.test.ts @@ -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([ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'quota', + ]); + }); + + it('removes workspace when hideCWD is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideCWD: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('workspace'); + }); + + it('removes sandbox when hideSandboxStatus is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('sandbox'); + }); + + it('removes model-name, context-used, 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-used'); + expect(items).not.toContain('quota'); + }); + + it('includes context-used when hideContextPercentage is false', () => { + const settings = createMockSettings({ + ui: { footer: { hideContextPercentage: false } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toContain('context-used'); + // Should be after model-name + const modelIdx = items.indexOf('model-name'); + const contextIdx = items.indexOf('context-used'); + 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', + 'context-used', + 'memory-usage', + ]); + }); +}); diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts new file mode 100644 index 0000000000..8410d0b5ec --- /dev/null +++ b/packages/cli/src/config/footerItems.ts @@ -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: 'workspace', + header: 'workspace (/directory)', + description: 'Current working directory', + }, + { + id: 'git-branch', + header: 'branch', + description: 'Current git branch name (not shown when unavailable)', + }, + { + id: 'sandbox', + header: 'sandbox', + description: 'Sandbox type and trust indicator', + }, + { + id: 'model-name', + header: '/model', + description: 'Current model identifier', + }, + { + id: 'context-used', + header: 'context', + description: 'Percentage of context window used', + }, + { + 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 = [ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'context-used', + 'quota', + 'memory-usage', + 'session-id', + 'code-changes', + 'token-count', +]; + +export function deriveItemsFromLegacySettings( + settings: MergedSettings, +): string[] { + const defaults = [ + 'workspace', + 'git-branch', + 'sandbox', + '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, 'workspace'); + if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox'); + if (settings.ui.footer.hideModelInfo) { + remove(items, 'model-name'); + remove(items, 'context-used'); + remove(items, 'quota'); + } + if ( + !settings.ui.footer.hideContextPercentage && + !items.includes('context-used') + ) { + const modelIdx = items.indexOf('model-name'); + if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-used'); + else items.push('context-used'); + } + if (settings.ui.showMemoryUsage) items.push('memory-usage'); + + return items; +} + +const VALID_IDS: Set = 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; +} { + 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), + }; +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8c0d13e2dd..fbc50e8b39 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -565,14 +565,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: { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 31673e921a..f867f84c80 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 86c46e79e5..3100673e94 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -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'; @@ -502,7 +503,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; diff --git a/packages/cli/src/ui/commands/footerCommand.tsx b/packages/cli/src/ui/commands/footerCommand.tsx new file mode 100644 index 0000000000..4a6760e229 --- /dev/null +++ b/packages/cli/src/ui/commands/footerCommand.tsx @@ -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: , + }), +}; diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index bcd5fd62b5..dcb2a3eae7 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -28,7 +28,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('50% context used'); + expect(output).toContain('50% used'); unmount(); }); @@ -42,7 +42,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('0% context used'); + expect(output).toContain('0% used'); unmount(); }); @@ -72,7 +72,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('80% context used'); + expect(output).toContain('80% used'); unmount(); }); @@ -86,7 +86,7 @@ describe('ContextUsageDisplay', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('100% context used'); + expect(output).toContain('100% used'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 66cb8ed234..3e82145dca 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -38,7 +38,7 @@ export const ContextUsageDisplay = ({ } const label = - terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used'; + terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% used'; return ( diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 7187240249..b79b005d85 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -4,16 +4,17 @@ * 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'; + +// Normalize paths to POSIX slashes for stable cross-platform snapshots. +const normalizeFrame = (frame: string | undefined) => { + if (!frame) return frame; + return frame.replace(/\\/g, '/'); +}; let mockIsDevelopment = false; @@ -49,14 +50,18 @@ const defaultProps = { branchName: 'main', }; -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, @@ -65,18 +70,39 @@ 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: {}, + }, }, }, }; describe('