Skip to content

Commit 475c25e

Browse files
jackwotherspoonkeithguerinjacob314
authored andcommitted
feat: add custom footer configuration via /footer (google-gemini#19001)
Co-authored-by: Keith Guerin <keithguerin@gmail.com> Co-authored-by: Jacob Richman <jacob314@gmail.com>
1 parent 1ec5cb9 commit 475c25e

19 files changed

+1625
-252
lines changed

docs/cli/settings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ they appear in the UI.
5757
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
5858
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
5959
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
60-
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
60+
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
6161
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
6262
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
6363
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |

docs/reference/configuration.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,18 @@ their corresponding top-level category object in your `settings.json` file.
250250
input.
251251
- **Default:** `false`
252252

253+
- **`ui.footer.items`** (array):
254+
- **Description:** List of item IDs to display in the footer. Rendered in
255+
order
256+
- **Default:** `undefined`
257+
258+
- **`ui.footer.showLabels`** (boolean):
259+
- **Description:** Display a second line above the footer items with
260+
descriptive headers (e.g., /model).
261+
- **Default:** `true`
262+
253263
- **`ui.footer.hideCWD`** (boolean):
254-
- **Description:** Hide the current working directory path in the footer.
264+
- **Description:** Hide the current working directory in the footer.
255265
- **Default:** `false`
256266

257267
- **`ui.footer.hideSandboxStatus`** (boolean):
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { deriveItemsFromLegacySettings } from './footerItems.js';
9+
import { createMockSettings } from '../test-utils/settings.js';
10+
11+
describe('deriveItemsFromLegacySettings', () => {
12+
it('returns defaults when no legacy settings are customized', () => {
13+
const settings = createMockSettings({
14+
ui: { footer: { hideContextPercentage: true } },
15+
}).merged;
16+
const items = deriveItemsFromLegacySettings(settings);
17+
expect(items).toEqual([
18+
'workspace',
19+
'git-branch',
20+
'sandbox',
21+
'model-name',
22+
'quota',
23+
]);
24+
});
25+
26+
it('removes workspace when hideCWD is true', () => {
27+
const settings = createMockSettings({
28+
ui: { footer: { hideCWD: true, hideContextPercentage: true } },
29+
}).merged;
30+
const items = deriveItemsFromLegacySettings(settings);
31+
expect(items).not.toContain('workspace');
32+
});
33+
34+
it('removes sandbox when hideSandboxStatus is true', () => {
35+
const settings = createMockSettings({
36+
ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } },
37+
}).merged;
38+
const items = deriveItemsFromLegacySettings(settings);
39+
expect(items).not.toContain('sandbox');
40+
});
41+
42+
it('removes model-name, context-used, and quota when hideModelInfo is true', () => {
43+
const settings = createMockSettings({
44+
ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
45+
}).merged;
46+
const items = deriveItemsFromLegacySettings(settings);
47+
expect(items).not.toContain('model-name');
48+
expect(items).not.toContain('context-used');
49+
expect(items).not.toContain('quota');
50+
});
51+
52+
it('includes context-used when hideContextPercentage is false', () => {
53+
const settings = createMockSettings({
54+
ui: { footer: { hideContextPercentage: false } },
55+
}).merged;
56+
const items = deriveItemsFromLegacySettings(settings);
57+
expect(items).toContain('context-used');
58+
// Should be after model-name
59+
const modelIdx = items.indexOf('model-name');
60+
const contextIdx = items.indexOf('context-used');
61+
expect(contextIdx).toBe(modelIdx + 1);
62+
});
63+
64+
it('includes memory-usage when showMemoryUsage is true', () => {
65+
const settings = createMockSettings({
66+
ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
67+
}).merged;
68+
const items = deriveItemsFromLegacySettings(settings);
69+
expect(items).toContain('memory-usage');
70+
});
71+
72+
it('handles combination of settings', () => {
73+
const settings = createMockSettings({
74+
ui: {
75+
showMemoryUsage: true,
76+
footer: {
77+
hideCWD: true,
78+
hideModelInfo: true,
79+
hideContextPercentage: false,
80+
},
81+
},
82+
}).merged;
83+
const items = deriveItemsFromLegacySettings(settings);
84+
expect(items).toEqual([
85+
'git-branch',
86+
'sandbox',
87+
'context-used',
88+
'memory-usage',
89+
]);
90+
});
91+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type { MergedSettings } from './settings.js';
8+
9+
export const ALL_ITEMS = [
10+
{
11+
id: 'workspace',
12+
header: 'workspace (/directory)',
13+
description: 'Current working directory',
14+
},
15+
{
16+
id: 'git-branch',
17+
header: 'branch',
18+
description: 'Current git branch name (not shown when unavailable)',
19+
},
20+
{
21+
id: 'sandbox',
22+
header: 'sandbox',
23+
description: 'Sandbox type and trust indicator',
24+
},
25+
{
26+
id: 'model-name',
27+
header: '/model',
28+
description: 'Current model identifier',
29+
},
30+
{
31+
id: 'context-used',
32+
header: 'context',
33+
description: 'Percentage of context window used',
34+
},
35+
{
36+
id: 'quota',
37+
header: '/stats',
38+
description: 'Remaining usage on daily limit (not shown when unavailable)',
39+
},
40+
{
41+
id: 'memory-usage',
42+
header: 'memory',
43+
description: 'Memory used by the application',
44+
},
45+
{
46+
id: 'session-id',
47+
header: 'session',
48+
description: 'Unique identifier for the current session',
49+
},
50+
{
51+
id: 'code-changes',
52+
header: 'diff',
53+
description: 'Lines added/removed in the session (not shown when zero)',
54+
},
55+
{
56+
id: 'token-count',
57+
header: 'tokens',
58+
description: 'Total tokens used in the session (not shown when zero)',
59+
},
60+
] as const;
61+
62+
export type FooterItemId = (typeof ALL_ITEMS)[number]['id'];
63+
64+
export const DEFAULT_ORDER = [
65+
'workspace',
66+
'git-branch',
67+
'sandbox',
68+
'model-name',
69+
'context-used',
70+
'quota',
71+
'memory-usage',
72+
'session-id',
73+
'code-changes',
74+
'token-count',
75+
];
76+
77+
export function deriveItemsFromLegacySettings(
78+
settings: MergedSettings,
79+
): string[] {
80+
const defaults = [
81+
'workspace',
82+
'git-branch',
83+
'sandbox',
84+
'model-name',
85+
'quota',
86+
];
87+
const items = [...defaults];
88+
89+
const remove = (arr: string[], id: string) => {
90+
const idx = arr.indexOf(id);
91+
if (idx !== -1) arr.splice(idx, 1);
92+
};
93+
94+
if (settings.ui.footer.hideCWD) remove(items, 'workspace');
95+
if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox');
96+
if (settings.ui.footer.hideModelInfo) {
97+
remove(items, 'model-name');
98+
remove(items, 'context-used');
99+
remove(items, 'quota');
100+
}
101+
if (
102+
!settings.ui.footer.hideContextPercentage &&
103+
!items.includes('context-used')
104+
) {
105+
const modelIdx = items.indexOf('model-name');
106+
if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-used');
107+
else items.push('context-used');
108+
}
109+
if (settings.ui.showMemoryUsage) items.push('memory-usage');
110+
111+
return items;
112+
}
113+
114+
const VALID_IDS: Set<string> = new Set(ALL_ITEMS.map((i) => i.id));
115+
116+
/**
117+
* Resolves the ordered list and selected set of footer items from settings.
118+
* Used by FooterConfigDialog to initialize and reset state.
119+
*/
120+
export function resolveFooterState(settings: MergedSettings): {
121+
orderedIds: string[];
122+
selectedIds: Set<string>;
123+
} {
124+
const source = (
125+
settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings)
126+
).filter((id: string) => VALID_IDS.has(id));
127+
const others = DEFAULT_ORDER.filter((id) => !source.includes(id));
128+
return {
129+
orderedIds: [...source, ...others],
130+
selectedIds: new Set(source),
131+
};
132+
}

packages/cli/src/config/settingsSchema.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,14 +565,34 @@ const SETTINGS_SCHEMA = {
565565
description: 'Settings for the footer.',
566566
showInDialog: false,
567567
properties: {
568+
items: {
569+
type: 'array',
570+
label: 'Footer Items',
571+
category: 'UI',
572+
requiresRestart: false,
573+
default: undefined as string[] | undefined,
574+
description:
575+
'List of item IDs to display in the footer. Rendered in order',
576+
showInDialog: false,
577+
items: { type: 'string' },
578+
},
579+
showLabels: {
580+
type: 'boolean',
581+
label: 'Show Footer Labels',
582+
category: 'UI',
583+
requiresRestart: false,
584+
default: true,
585+
description:
586+
'Display a second line above the footer items with descriptive headers (e.g., /model).',
587+
showInDialog: false,
588+
},
568589
hideCWD: {
569590
type: 'boolean',
570591
label: 'Hide CWD',
571592
category: 'UI',
572593
requiresRestart: false,
573594
default: false,
574-
description:
575-
'Hide the current working directory path in the footer.',
595+
description: 'Hide the current working directory in the footer.',
576596
showInDialog: true,
577597
},
578598
hideSandboxStatus: {

packages/cli/src/services/BuiltinCommandLoader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
3131
import { directoryCommand } from '../ui/commands/directoryCommand.js';
3232
import { editorCommand } from '../ui/commands/editorCommand.js';
3333
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
34+
import { footerCommand } from '../ui/commands/footerCommand.js';
3435
import { helpCommand } from '../ui/commands/helpCommand.js';
3536
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
3637
import { rewindCommand } from '../ui/commands/rewindCommand.js';
@@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
119120
]
120121
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
121122
helpCommand,
123+
footerCommand,
122124
shortcutsCommand,
123125
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
124126
rewindCommand,

packages/cli/src/test-utils/render.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { vi } from 'vitest';
1717
import stripAnsi from 'strip-ansi';
1818
import { act, useState } from 'react';
1919
import os from 'node:os';
20+
import path from 'node:path';
2021
import { LoadedSettings } from '../config/settings.js';
2122
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
2223
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
@@ -502,7 +503,22 @@ const configProxy = new Proxy({} as Config, {
502503
get(_target, prop) {
503504
if (prop === 'getTargetDir') {
504505
return () =>
505-
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
506+
path.join(
507+
path.parse(process.cwd()).root,
508+
'Users',
509+
'test',
510+
'project',
511+
'foo',
512+
'bar',
513+
'and',
514+
'some',
515+
'more',
516+
'directories',
517+
'to',
518+
'make',
519+
'it',
520+
'long',
521+
);
506522
}
507523
if (prop === 'getUseBackgroundColor') {
508524
return () => true;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
type SlashCommand,
9+
type CommandContext,
10+
type OpenCustomDialogActionReturn,
11+
CommandKind,
12+
} from './types.js';
13+
import { FooterConfigDialog } from '../components/FooterConfigDialog.js';
14+
15+
export const footerCommand: SlashCommand = {
16+
name: 'footer',
17+
altNames: ['statusline'],
18+
description: 'Configure which items appear in the footer (statusline)',
19+
kind: CommandKind.BUILT_IN,
20+
autoExecute: true,
21+
action: (context: CommandContext): OpenCustomDialogActionReturn => ({
22+
type: 'custom_dialog',
23+
component: <FooterConfigDialog onClose={context.ui.removeComponent} />,
24+
}),
25+
};

0 commit comments

Comments
 (0)