From c0c3885fa5848f3624b61237f584b8cd9cb93522 Mon Sep 17 00:00:00 2001 From: Oerum <54005601+Oerum@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:42:24 +0100 Subject: [PATCH 1/2] feat(cli): add shortcuts to /memory list and interactive UI --- packages/cli/src/config/keyBindings.ts | 4 + .../cli/src/ui/commands/memoryCommand.test.ts | 32 ++-- packages/cli/src/ui/commands/memoryCommand.ts | 26 ++- packages/cli/src/ui/components/MemoryList.tsx | 160 ++++++++++++++++++ 4 files changed, 189 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/ui/components/MemoryList.tsx diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 4813abd3680..85163bb8525 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -97,6 +97,7 @@ export enum Command { CLEAR_SCREEN = 'app.clearScreen', RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', + OPEN_DIRECTORY = 'app.openDirectory', } /** @@ -297,6 +298,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], + [Command.OPEN_DIRECTORY]: [{ key: 'o', alt: true }], }; interface CommandCategory { @@ -417,6 +419,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.CLEAR_SCREEN, Command.RESTART_APP, Command.SUSPEND_APP, + Command.OPEN_DIRECTORY, ], }, ]; @@ -525,4 +528,5 @@ export const commandDescriptions: Readonly> = { [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.RESTART_APP]: 'Restart the application.', [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.', + [Command.OPEN_DIRECTORY]: 'Open the directory containing the file.', }; diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 1a2c7e39362..b55adef69d8 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -421,37 +421,33 @@ describe('memoryCommand', () => { }); }); - it('should display a message if no GEMINI.md files are found', async () => { + it('should return a custom dialog component with empty file paths if no GEMINI.md files are found', async () => { if (!listCommand.action) throw new Error('Command has no action'); mockGetGeminiMdfilePaths.mockReturnValue([]); - await listCommand.action(mockContext, ''); + const result = await listCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No GEMINI.md files in use.', - }, - expect.any(Number), - ); + expect(result).toMatchObject({ + type: 'custom_dialog', + component: expect.anything(), + }); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); }); - it('should display the file count and paths if they exist', async () => { + it('should return a custom dialog component with file paths if they exist', async () => { if (!listCommand.action) throw new Error('Command has no action'); const filePaths = ['/path/one/GEMINI.md', '/path/two/GEMINI.md']; mockGetGeminiMdfilePaths.mockReturnValue(filePaths); - await listCommand.action(mockContext, ''); + const result = await listCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `There are 2 GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`, - }, - expect.any(Number), - ); + expect(result).toMatchObject({ + type: 'custom_dialog', + component: expect.anything(), + }); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index a31280f824b..97082d3d822 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -4,15 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - addMemory, - listMemoryFiles, - refreshMemory, - showMemory, -} from '@google/gemini-cli-core'; +import { addMemory, refreshMemory, showMemory } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; +import React from 'react'; +import { MemoryList } from '../components/MemoryList.js'; export const memoryCommand: SlashCommand = { name: 'memory', @@ -107,18 +104,17 @@ export const memoryCommand: SlashCommand = { description: 'Lists the paths of the GEMINI.md files in use', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async (context) => { + action: async (context): Promise => { const config = context.services.config; if (!config) return; - const result = listMemoryFiles(config); - context.ui.addItem( - { - type: MessageType.INFO, - text: result.content, - }, - Date.now(), - ); + return { + type: 'custom_dialog', + component: React.createElement(MemoryList, { + filePaths: config.getGeminiMdFilePaths() || [], + onClose: () => context.ui.removeComponent(), + }), + }; }, }, ], diff --git a/packages/cli/src/ui/components/MemoryList.tsx b/packages/cli/src/ui/components/MemoryList.tsx new file mode 100644 index 00000000000..c34fe29f0b4 --- /dev/null +++ b/packages/cli/src/ui/components/MemoryList.tsx @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { BaseSelectionList } from './shared/BaseSelectionList.js'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import open from 'open'; +import path from 'node:path'; + +interface MemoryListProps { + filePaths: string[]; + onClose: () => void; +} + +export const MemoryList: React.FC = ({ + filePaths, + onClose, +}) => { + const { terminalWidth, terminalHeight } = useUIState(); + const [highlightedIndex, setHighlightedIndex] = useState(0); + + const items = useMemo( + () => + filePaths.map((filePath, index) => ({ + key: `file-${index}`, + value: filePath, + index, + })), + [filePaths], + ); + + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return true; + } + + if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { + const selectedFile = filePaths[highlightedIndex]; + if (selectedFile) { + try { + // Using open package so we do not run into command injection issues + // eslint-disable-next-line @typescript-eslint/no-floating-promises + open(selectedFile); + } catch (_e) { + // silently fail or display an error + } + } + return true; + } + + if (keyMatchers[Command.OPEN_DIRECTORY](key)) { + const selectedFile = filePaths[highlightedIndex]; + if (selectedFile) { + try { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + open(path.dirname(selectedFile)); + } catch (_e) { + // silently fail or display an error + } + } + return true; + } + + return false; + }, + { isActive: true }, + ); + + const DIALOG_PADDING = 2; + const HEADER_HEIGHT = 2; + const CONTROLS_HEIGHT = 2; + + const listHeight = Math.max( + 5, + terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2, + ); + + const maxItemsToShow = Math.max(1, Math.floor(listHeight)); + + if (filePaths.length === 0) { + return ( + + No GEMINI.md files found. + + ); + } + + return ( + + + {'> '}Memory Files + + + + { + // Do nothing on enter for now, or just close + onClose(); + }} + onHighlight={(item: string) => { + const index = filePaths.indexOf(item); + if (index !== -1) { + setHighlightedIndex(index); + } + }} + maxItemsToShow={maxItemsToShow} + renderItem={(itemWrapper, { isSelected }) => { + const filePath = itemWrapper.value; + const basename = path.basename(filePath); + const dir = path.dirname(filePath); + + return ( + + + {basename} + + {dir} + + ); + }} + /> + + + + + (Use Esc to close, Ctrl+X to open in editor, Alt+O to open folder) + + + + ); +}; From c965a4ce42ab173994a4bf9fe992aa4e25c11758 Mon Sep 17 00:00:00 2001 From: Oerum <54005601+Oerum@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:18:16 +0100 Subject: [PATCH 2/2] fix(cli): handle unhandled promise rejections in memory list shortcuts --- packages/cli/src/ui/commands/memoryCommand.ts | 5 +++++ packages/cli/src/ui/components/MemoryList.tsx | 22 ++++++++----------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 97082d3d822..63c6f36772e 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -113,6 +113,11 @@ export const memoryCommand: SlashCommand = { component: React.createElement(MemoryList, { filePaths: config.getGeminiMdFilePaths() || [], onClose: () => context.ui.removeComponent(), + onError: (message: string) => + context.ui.addItem( + { type: MessageType.ERROR, text: message }, + Date.now(), + ), }), }; }, diff --git a/packages/cli/src/ui/components/MemoryList.tsx b/packages/cli/src/ui/components/MemoryList.tsx index c34fe29f0b4..ef96cb2c3a9 100644 --- a/packages/cli/src/ui/components/MemoryList.tsx +++ b/packages/cli/src/ui/components/MemoryList.tsx @@ -18,11 +18,13 @@ import path from 'node:path'; interface MemoryListProps { filePaths: string[]; onClose: () => void; + onError: (message: string) => void; } export const MemoryList: React.FC = ({ filePaths, onClose, + onError, }) => { const { terminalWidth, terminalHeight } = useUIState(); const [highlightedIndex, setHighlightedIndex] = useState(0); @@ -47,13 +49,10 @@ export const MemoryList: React.FC = ({ if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { const selectedFile = filePaths[highlightedIndex]; if (selectedFile) { - try { - // Using open package so we do not run into command injection issues - // eslint-disable-next-line @typescript-eslint/no-floating-promises - open(selectedFile); - } catch (_e) { - // silently fail or display an error - } + // Using open package so we do not run into command injection issues + open(selectedFile).catch((e: Error) => { + onError(`Failed to open file: ${e.message}`); + }); } return true; } @@ -61,12 +60,9 @@ export const MemoryList: React.FC = ({ if (keyMatchers[Command.OPEN_DIRECTORY](key)) { const selectedFile = filePaths[highlightedIndex]; if (selectedFile) { - try { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - open(path.dirname(selectedFile)); - } catch (_e) { - // silently fail or display an error - } + open(path.dirname(selectedFile)).catch((e: Error) => { + onError(`Failed to open directory: ${e.message}`); + }); } return true; }