mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 22:55:13 +00:00
feat: implement /rewind command (#15720)
This commit is contained in:
@@ -62,6 +62,7 @@ available combinations.
|
|||||||
| Start reverse search through history. | `Ctrl + R` |
|
| Start reverse search through history. | `Ctrl + R` |
|
||||||
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
||||||
| Accept a suggestion while reverse searching. | `Tab` |
|
| Accept a suggestion while reverse searching. | `Tab` |
|
||||||
|
| Browse and rewind previous interactions. | `Double Esc` |
|
||||||
|
|
||||||
#### Navigation
|
#### Navigation
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export enum Command {
|
|||||||
REVERSE_SEARCH = 'history.search.start',
|
REVERSE_SEARCH = 'history.search.start',
|
||||||
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
||||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
||||||
|
REWIND = 'history.rewind',
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
NAVIGATION_UP = 'nav.up',
|
NAVIGATION_UP = 'nav.up',
|
||||||
@@ -188,6 +189,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||||||
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
|
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
|
||||||
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
|
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
|
||||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||||
|
[Command.REWIND]: [{ key: 'double escape' }],
|
||||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||||
|
|
||||||
@@ -317,6 +319,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
|||||||
Command.REVERSE_SEARCH,
|
Command.REVERSE_SEARCH,
|
||||||
Command.SUBMIT_REVERSE_SEARCH,
|
Command.SUBMIT_REVERSE_SEARCH,
|
||||||
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
||||||
|
Command.REWIND,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -413,6 +416,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
|||||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||||
'Accept a suggestion while reverse searching.',
|
'Accept a suggestion while reverse searching.',
|
||||||
|
[Command.REWIND]: 'Browse and rewind previous interactions.',
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
|||||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
|
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||||
import { initCommand } from '../ui/commands/initCommand.js';
|
import { initCommand } from '../ui/commands/initCommand.js';
|
||||||
@@ -106,6 +107,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||||
helpCommand,
|
helpCommand,
|
||||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||||
|
rewindCommand,
|
||||||
await ideCommand(),
|
await ideCommand(),
|
||||||
initCommand,
|
initCommand,
|
||||||
...(this.config?.getMcpEnabled() === false
|
...(this.config?.getMcpEnabled() === false
|
||||||
|
|||||||
@@ -692,6 +692,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
toggleDebugProfiler,
|
toggleDebugProfiler,
|
||||||
dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate,
|
||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
|
setText: (text: string) => buffer.setText(text),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
setAuthState,
|
setAuthState,
|
||||||
@@ -708,6 +709,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
toggleDebugProfiler,
|
toggleDebugProfiler,
|
||||||
|
buffer,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
351
packages/cli/src/ui/commands/rewindCommand.test.tsx
Normal file
351
packages/cli/src/ui/commands/rewindCommand.test.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { rewindCommand } from './rewindCommand.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
|
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||||
|
import {
|
||||||
|
type OpenCustomDialogActionReturn,
|
||||||
|
type CommandContext,
|
||||||
|
} from './types.js';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { coreEvents } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockRewindTo = vi.fn();
|
||||||
|
const mockRecordMessage = vi.fn();
|
||||||
|
const mockSetHistory = vi.fn();
|
||||||
|
const mockSendMessageStream = vi.fn();
|
||||||
|
const mockGetChatRecordingService = vi.fn();
|
||||||
|
const mockGetConversation = vi.fn();
|
||||||
|
const mockRemoveComponent = vi.fn();
|
||||||
|
const mockLoadHistory = vi.fn();
|
||||||
|
const mockAddItem = vi.fn();
|
||||||
|
const mockSetPendingItem = vi.fn();
|
||||||
|
const mockResetContext = vi.fn();
|
||||||
|
const mockSetInput = vi.fn();
|
||||||
|
const mockRevertFileChanges = vi.fn();
|
||||||
|
const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root');
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
coreEvents: {
|
||||||
|
...actual.coreEvents,
|
||||||
|
emitFeedback: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../components/RewindViewer.js', () => ({
|
||||||
|
RewindViewer: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/useSessionBrowser.js', () => ({
|
||||||
|
convertSessionToHistoryFormats: vi.fn().mockReturnValue({
|
||||||
|
uiHistory: [
|
||||||
|
{ type: 'user', text: 'old user' },
|
||||||
|
{ type: 'gemini', text: 'old gemini' },
|
||||||
|
],
|
||||||
|
clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/rewindFileOps.js', () => ({
|
||||||
|
revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface RewindViewerProps {
|
||||||
|
onRewind: (
|
||||||
|
messageId: string,
|
||||||
|
newText: string,
|
||||||
|
outcome: RewindOutcome,
|
||||||
|
) => Promise<void>;
|
||||||
|
conversation: unknown;
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rewindCommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockGetConversation.mockReturnValue({
|
||||||
|
messages: [{ id: 'msg-1', type: 'user', content: 'hello' }],
|
||||||
|
sessionId: 'test-session',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRewindTo.mockReturnValue({
|
||||||
|
messages: [], // Mocked rewound messages
|
||||||
|
});
|
||||||
|
|
||||||
|
mockGetChatRecordingService.mockReturnValue({
|
||||||
|
getConversation: mockGetConversation,
|
||||||
|
rewindTo: mockRewindTo,
|
||||||
|
recordMessage: mockRecordMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
getGeminiClient: () => ({
|
||||||
|
getChatRecordingService: mockGetChatRecordingService,
|
||||||
|
setHistory: mockSetHistory,
|
||||||
|
sendMessageStream: mockSendMessageStream,
|
||||||
|
}),
|
||||||
|
getSessionId: () => 'test-session-id',
|
||||||
|
getContextManager: () => ({ refresh: mockResetContext }),
|
||||||
|
getProjectRoot: mockGetProjectRoot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
removeComponent: mockRemoveComponent,
|
||||||
|
loadHistory: mockLoadHistory,
|
||||||
|
addItem: mockAddItem,
|
||||||
|
setPendingItem: mockSetPendingItem,
|
||||||
|
},
|
||||||
|
}) as unknown as CommandContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize successfully', async () => {
|
||||||
|
const result = await rewindCommand.action!(mockContext, '');
|
||||||
|
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RewindOnly correctly', async () => {
|
||||||
|
// 1. Run the command to get the component
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
|
||||||
|
// Access onRewind from props
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
expect(onRewind).toBeDefined();
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
|
||||||
|
expect(mockSetHistory).toHaveBeenCalled();
|
||||||
|
expect(mockResetContext).toHaveBeenCalled();
|
||||||
|
expect(mockLoadHistory).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
expect.objectContaining({ text: 'old user', id: 1 }),
|
||||||
|
expect.objectContaining({ text: 'old gemini', id: 2 }),
|
||||||
|
],
|
||||||
|
'New Prompt',
|
||||||
|
);
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify setInput was NOT called directly (it's handled via loadHistory now)
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RewindAndRevert correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).toHaveBeenCalledWith(
|
||||||
|
mockGetConversation(),
|
||||||
|
'msg-id-123',
|
||||||
|
);
|
||||||
|
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
|
||||||
|
expect(mockLoadHistory).toHaveBeenCalledWith(
|
||||||
|
expect.any(Array),
|
||||||
|
'New Prompt',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RevertOnly correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).toHaveBeenCalledWith(
|
||||||
|
mockGetConversation(),
|
||||||
|
'msg-id-123',
|
||||||
|
);
|
||||||
|
expect(mockRewindTo).not.toHaveBeenCalled();
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
|
'info',
|
||||||
|
'File changes reverted.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Cancel correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockRewindTo).not.toHaveBeenCalled();
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle onExit correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onExit = component.props.onExit;
|
||||||
|
|
||||||
|
onExit();
|
||||||
|
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rewind error correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
mockRewindTo.mockImplementation(() => {
|
||||||
|
throw new Error('Rewind Failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'Rewind Failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null conversation from rewindTo', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
mockRewindTo.mockReturnValue(null);
|
||||||
|
|
||||||
|
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'Could not fetch conversation file',
|
||||||
|
);
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if config is missing', () => {
|
||||||
|
const context = { services: {} } as CommandContext;
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(context, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Config not found',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if client is not initialized', () => {
|
||||||
|
const context = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: { getGeminiClient: () => undefined },
|
||||||
|
},
|
||||||
|
}) as unknown as CommandContext;
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(context, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Client not initialized',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if recording service is unavailable', () => {
|
||||||
|
const context = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
getGeminiClient: () => ({ getChatRecordingService: () => undefined }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as unknown as CommandContext;
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(context, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Recording service unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return info if no conversation found', () => {
|
||||||
|
mockGetConversation.mockReturnValue(null);
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'No conversation found.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return info if no user interactions found', () => {
|
||||||
|
mockGetConversation.mockReturnValue({
|
||||||
|
messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }],
|
||||||
|
sessionId: 'test-session',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Nothing to rewind to.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
191
packages/cli/src/ui/commands/rewindCommand.tsx
Normal file
191
packages/cli/src/ui/commands/rewindCommand.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommandKind,
|
||||||
|
type CommandContext,
|
||||||
|
type SlashCommand,
|
||||||
|
} from './types.js';
|
||||||
|
import { RewindViewer } from '../components/RewindViewer.js';
|
||||||
|
import { type HistoryItem } from '../types.js';
|
||||||
|
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
|
||||||
|
import { revertFileChanges } from '../utils/rewindFileOps.js';
|
||||||
|
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||||
|
import { checkExhaustive } from '../../utils/checks.js';
|
||||||
|
|
||||||
|
import type { Content } from '@google/genai';
|
||||||
|
import type {
|
||||||
|
ChatRecordingService,
|
||||||
|
GeminiClient,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to handle the core logic of rewinding a conversation.
|
||||||
|
* This function encapsulates the steps needed to rewind the conversation,
|
||||||
|
* update the client and UI history, and clear the component.
|
||||||
|
*
|
||||||
|
* @param context The command context.
|
||||||
|
* @param client Gemini client
|
||||||
|
* @param recordingService The chat recording service.
|
||||||
|
* @param messageId The ID of the message to rewind to.
|
||||||
|
* @param newText The new text for the input field after rewinding.
|
||||||
|
*/
|
||||||
|
async function rewindConversation(
|
||||||
|
context: CommandContext,
|
||||||
|
client: GeminiClient,
|
||||||
|
recordingService: ChatRecordingService,
|
||||||
|
messageId: string,
|
||||||
|
newText: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const conversation = recordingService.rewindTo(messageId);
|
||||||
|
if (!conversation) {
|
||||||
|
const errorMsg = 'Could not fetch conversation file';
|
||||||
|
debugLogger.error(errorMsg);
|
||||||
|
context.ui.removeComponent();
|
||||||
|
coreEvents.emitFeedback('error', errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to UI and Client formats
|
||||||
|
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
|
||||||
|
conversation.messages,
|
||||||
|
);
|
||||||
|
|
||||||
|
client.setHistory(clientHistory as Content[]);
|
||||||
|
|
||||||
|
// Reset context manager as we are rewinding history
|
||||||
|
await context.services.config?.getContextManager()?.refresh();
|
||||||
|
|
||||||
|
// Update UI History
|
||||||
|
// We generate IDs based on index for the rewind history
|
||||||
|
const startId = 1;
|
||||||
|
const historyWithIds = uiHistory.map(
|
||||||
|
(item, idx) =>
|
||||||
|
({
|
||||||
|
...item,
|
||||||
|
id: startId + idx,
|
||||||
|
}) as HistoryItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Remove component FIRST to avoid flicker and clear the stage
|
||||||
|
context.ui.removeComponent();
|
||||||
|
|
||||||
|
// 2. Load the rewound history and set the input
|
||||||
|
context.ui.loadHistory(historyWithIds, newText);
|
||||||
|
} catch (error) {
|
||||||
|
// If an error occurs, we still want to remove the component if possible
|
||||||
|
context.ui.removeComponent();
|
||||||
|
coreEvents.emitFeedback(
|
||||||
|
'error',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error during rewind',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rewindCommand: SlashCommand = {
|
||||||
|
name: 'rewind',
|
||||||
|
description: 'Jump back to a specific message and restart the conversation',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: (context) => {
|
||||||
|
const config = context.services.config;
|
||||||
|
if (!config)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Config not found',
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = config.getGeminiClient();
|
||||||
|
if (!client)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Client not initialized',
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordingService = client.getChatRecordingService();
|
||||||
|
if (!recordingService)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Recording service unavailable',
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversation = recordingService.getConversation();
|
||||||
|
if (!conversation)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'No conversation found.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserInteractions = conversation.messages.some(
|
||||||
|
(msg) => msg.type === 'user',
|
||||||
|
);
|
||||||
|
if (!hasUserInteractions) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Nothing to rewind to.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'custom_dialog',
|
||||||
|
component: (
|
||||||
|
<RewindViewer
|
||||||
|
conversation={conversation}
|
||||||
|
onExit={() => {
|
||||||
|
context.ui.removeComponent();
|
||||||
|
}}
|
||||||
|
onRewind={async (messageId, newText, outcome) => {
|
||||||
|
switch (outcome) {
|
||||||
|
case RewindOutcome.Cancel:
|
||||||
|
context.ui.removeComponent();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case RewindOutcome.RevertOnly:
|
||||||
|
if (conversation) {
|
||||||
|
await revertFileChanges(conversation, messageId);
|
||||||
|
}
|
||||||
|
context.ui.removeComponent();
|
||||||
|
coreEvents.emitFeedback('info', 'File changes reverted.');
|
||||||
|
return;
|
||||||
|
|
||||||
|
case RewindOutcome.RewindAndRevert:
|
||||||
|
if (conversation) {
|
||||||
|
await revertFileChanges(conversation, messageId);
|
||||||
|
}
|
||||||
|
await rewindConversation(
|
||||||
|
context,
|
||||||
|
client,
|
||||||
|
recordingService,
|
||||||
|
messageId,
|
||||||
|
newText,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case RewindOutcome.RewindOnly:
|
||||||
|
await rewindConversation(
|
||||||
|
context,
|
||||||
|
client,
|
||||||
|
recordingService,
|
||||||
|
messageId,
|
||||||
|
newText,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
|
||||||
|
default:
|
||||||
|
checkExhaustive(outcome);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -66,8 +66,9 @@ export interface CommandContext {
|
|||||||
* Loads a new set of history items, replacing the current history.
|
* Loads a new set of history items, replacing the current history.
|
||||||
*
|
*
|
||||||
* @param history The array of history items to load.
|
* @param history The array of history items to load.
|
||||||
|
* @param postLoadInput Optional text to set in the input buffer after loading history.
|
||||||
*/
|
*/
|
||||||
loadHistory: UseHistoryManagerReturn['loadHistory'];
|
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
|
||||||
/** Toggles a special display mode. */
|
/** Toggles a special display mode. */
|
||||||
toggleCorgiMode: () => void;
|
toggleCorgiMode: () => void;
|
||||||
toggleDebugProfiler: () => void;
|
toggleDebugProfiler: () => void;
|
||||||
|
|||||||
@@ -2007,7 +2007,9 @@ describe('InputPrompt', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\x1B\x1B');
|
stdin.write('\x1B\x1B');
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||||
});
|
});
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
|
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
parseInputForHighlighting,
|
parseInputForHighlighting,
|
||||||
parseSegmentsFromTokens,
|
parseSegmentsFromTokens,
|
||||||
@@ -516,18 +516,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
escapeTimerRef.current = setTimeout(() => {
|
escapeTimerRef.current = setTimeout(() => {
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
return;
|
||||||
// Second ESC
|
|
||||||
resetEscapeState();
|
|
||||||
if (buffer.text.length > 0) {
|
|
||||||
buffer.setText('');
|
|
||||||
resetCompletionState();
|
|
||||||
} else {
|
|
||||||
if (history.length > 0) {
|
|
||||||
onSubmit('/rewind');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second ESC
|
||||||
|
resetEscapeState();
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
buffer.setText('');
|
||||||
|
resetCompletionState();
|
||||||
|
return;
|
||||||
|
} else if (history.length > 0) {
|
||||||
|
onSubmit('/rewind');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,17 +252,16 @@ describe('RewindViewer', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
description: 'removes reference markers',
|
description: 'removes reference markers',
|
||||||
prompt:
|
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
|
||||||
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'strips expanded MCP resource content',
|
description: 'strips expanded MCP resource content',
|
||||||
prompt:
|
prompt:
|
||||||
'read @server3:mcp://demo-resource hello\n' +
|
'read @server3:mcp://demo-resource hello\n' +
|
||||||
'--- Content from referenced files ---\n' +
|
`--- Content from referenced files ---\n` +
|
||||||
'\nContent from @server3:mcp://demo-resource:\n' +
|
'\nContent from @server3:mcp://demo-resource:\n' +
|
||||||
'This is the content of the demo resource.\n' +
|
'This is the content of the demo resource.\n' +
|
||||||
'--- End of content ---',
|
`--- End of content ---`,
|
||||||
},
|
},
|
||||||
])('$description', async ({ prompt }) => {
|
])('$description', async ({ prompt }) => {
|
||||||
const conversation = createConversation([
|
const conversation = createConversation([
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import {
|
import {
|
||||||
@@ -19,8 +19,9 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||||||
import { useRewind } from '../hooks/useRewind.js';
|
import { useRewind } from '../hooks/useRewind.js';
|
||||||
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
|
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
|
||||||
import { stripReferenceContent } from '../utils/formatters.js';
|
import { stripReferenceContent } from '../utils/formatters.js';
|
||||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
|
||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
|
import { CliSpinner } from './CliSpinner.js';
|
||||||
|
import { ExpandableText } from './shared/ExpandableText.js';
|
||||||
|
|
||||||
interface RewindViewerProps {
|
interface RewindViewerProps {
|
||||||
conversation: ConversationRecord;
|
conversation: ConversationRecord;
|
||||||
@@ -29,7 +30,7 @@ interface RewindViewerProps {
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
newText: string,
|
newText: string,
|
||||||
outcome: RewindOutcome,
|
outcome: RewindOutcome,
|
||||||
) => void;
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LINES_PER_BOX = 2;
|
const MAX_LINES_PER_BOX = 2;
|
||||||
@@ -39,6 +40,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
onExit,
|
onExit,
|
||||||
onRewind,
|
onRewind,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isRewinding, setIsRewinding] = useState(false);
|
||||||
const { terminalWidth, terminalHeight } = useUIState();
|
const { terminalWidth, terminalHeight } = useUIState();
|
||||||
const {
|
const {
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
@@ -48,28 +50,58 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
} = useRewind(conversation);
|
} = useRewind(conversation);
|
||||||
|
|
||||||
|
const [highlightedMessageId, setHighlightedMessageId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const interactions = useMemo(
|
const interactions = useMemo(
|
||||||
() => conversation.messages.filter((msg) => msg.type === 'user'),
|
() => conversation.messages.filter((msg) => msg.type === 'user'),
|
||||||
[conversation.messages],
|
[conversation.messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(() => {
|
||||||
() =>
|
const interactionItems = interactions.map((msg, idx) => ({
|
||||||
interactions
|
key: `${msg.id || 'msg'}-${idx}`,
|
||||||
.map((msg, idx) => ({
|
value: msg,
|
||||||
key: `${msg.id || 'msg'}-${idx}`,
|
index: idx,
|
||||||
value: msg,
|
}));
|
||||||
index: idx,
|
|
||||||
}))
|
// Add "Current Position" as the last item
|
||||||
.reverse(),
|
return [
|
||||||
[interactions],
|
...interactionItems,
|
||||||
);
|
{
|
||||||
|
key: 'current-position',
|
||||||
|
value: {
|
||||||
|
id: 'current-position',
|
||||||
|
type: 'user',
|
||||||
|
content: 'Stay at current position',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
} as MessageRecord,
|
||||||
|
index: interactionItems.length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [interactions]);
|
||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
if (!selectedMessageId) {
|
if (!selectedMessageId) {
|
||||||
if (keyMatchers[Command.ESCAPE](key)) {
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
onExit();
|
onExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||||
|
if (
|
||||||
|
highlightedMessageId &&
|
||||||
|
highlightedMessageId !== 'current-position'
|
||||||
|
) {
|
||||||
|
setExpandedMessageId(highlightedMessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||||
|
setExpandedMessageId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,6 +121,28 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
||||||
|
|
||||||
if (selectedMessageId) {
|
if (selectedMessageId) {
|
||||||
|
if (isRewinding) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
padding={1}
|
||||||
|
width={terminalWidth}
|
||||||
|
flexDirection="row"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<CliSpinner />
|
||||||
|
</Box>
|
||||||
|
<Text>Rewinding...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMessageId === 'current-position') {
|
||||||
|
onExit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedMessage = interactions.find(
|
const selectedMessage = interactions.find(
|
||||||
(m) => m.id === selectedMessageId,
|
(m) => m.id === selectedMessageId,
|
||||||
);
|
);
|
||||||
@@ -97,7 +151,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
stats={confirmationStats}
|
stats={confirmationStats}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
timestamp={selectedMessage?.timestamp}
|
timestamp={selectedMessage?.timestamp}
|
||||||
onConfirm={(outcome) => {
|
onConfirm={async (outcome) => {
|
||||||
if (outcome === RewindOutcome.Cancel) {
|
if (outcome === RewindOutcome.Cancel) {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} else {
|
} else {
|
||||||
@@ -109,7 +163,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
? partToString(userPrompt.content)
|
? partToString(userPrompt.content)
|
||||||
: '';
|
: '';
|
||||||
const cleanedText = stripReferenceContent(originalUserText);
|
const cleanedText = stripReferenceContent(originalUserText);
|
||||||
onRewind(selectedMessageId, cleanedText, outcome);
|
setIsRewinding(true);
|
||||||
|
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -138,12 +193,41 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
onSelect={(item: MessageRecord) => {
|
onSelect={(item: MessageRecord) => {
|
||||||
const userPrompt = item;
|
const userPrompt = item;
|
||||||
if (userPrompt && userPrompt.id) {
|
if (userPrompt && userPrompt.id) {
|
||||||
selectMessage(userPrompt.id);
|
if (userPrompt.id === 'current-position') {
|
||||||
|
onExit();
|
||||||
|
} else {
|
||||||
|
selectMessage(userPrompt.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onHighlight={(item: MessageRecord) => {
|
||||||
|
if (item.id) {
|
||||||
|
setHighlightedMessageId(item.id);
|
||||||
|
// Collapse when moving selection
|
||||||
|
setExpandedMessageId(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
maxItemsToShow={maxItemsToShow}
|
maxItemsToShow={maxItemsToShow}
|
||||||
renderItem={(itemWrapper, { isSelected }) => {
|
renderItem={(itemWrapper, { isSelected }) => {
|
||||||
const userPrompt = itemWrapper.value;
|
const userPrompt = itemWrapper.value;
|
||||||
|
|
||||||
|
if (userPrompt.id === 'current-position') {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
isSelected ? theme.status.success : theme.text.primary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{partToString(userPrompt.content)}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Cancel rewind and stay here
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const stats = getStats(userPrompt);
|
const stats = getStats(userPrompt);
|
||||||
const firstFileName = stats?.details?.at(0)?.fileName;
|
const firstFileName = stats?.details?.at(0)?.fileName;
|
||||||
const originalUserText = userPrompt.content
|
const originalUserText = userPrompt.content
|
||||||
@@ -154,25 +238,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Box>
|
<Box>
|
||||||
<MaxSizedBox
|
<ExpandableText
|
||||||
maxWidth={terminalWidth - 4}
|
label={cleanedText}
|
||||||
maxHeight={isSelected ? undefined : MAX_LINES_PER_BOX + 1}
|
isExpanded={expandedMessageId === userPrompt.id}
|
||||||
overflowDirection="bottom"
|
textColor={
|
||||||
>
|
isSelected ? theme.status.success : theme.text.primary
|
||||||
{cleanedText.split('\n').map((line, i) => (
|
}
|
||||||
<Box key={i}>
|
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
|
||||||
<Text
|
maxLines={MAX_LINES_PER_BOX}
|
||||||
color={
|
/>
|
||||||
isSelected
|
|
||||||
? theme.status.success
|
|
||||||
: theme.text.primary
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</MaxSizedBox>
|
|
||||||
</Box>
|
</Box>
|
||||||
{stats ? (
|
{stats ? (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
@@ -203,7 +277,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
(Use Enter to select a message, Esc to close)
|
(Use Enter to select a message, Esc to close, Right/Left to
|
||||||
|
expand/collapse)
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
|
||||||
import { CommandKind } from '../commands/types.js';
|
import { CommandKind } from '../commands/types.js';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
@@ -85,7 +85,7 @@ export function SuggestionsDisplay({
|
|||||||
const textColor = isActive ? theme.text.accent : theme.text.secondary;
|
const textColor = isActive ? theme.text.accent : theme.text.secondary;
|
||||||
const isLong = suggestion.value.length >= MAX_WIDTH;
|
const isLong = suggestion.value.length >= MAX_WIDTH;
|
||||||
const labelElement = (
|
const labelElement = (
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={suggestion.value}
|
label={suggestion.value}
|
||||||
matchedIndex={suggestion.matchedIndex}
|
matchedIndex={suggestion.matchedIndex}
|
||||||
userInput={userInput}
|
userInput={userInput}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
|
|||||||
│ ● some command @file │
|
│ ● some command @file │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -22,8 +25,11 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
|
|||||||
│ ● read @server3:mcp://demo-resource hello │
|
│ ● read @server3:mcp://demo-resource hello │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -63,17 +69,20 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ Q3 │
|
│ Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ ● Q2 │
|
│ ● Q2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q1 │
|
│ Q3 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -83,17 +92,20 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ Q3 │
|
│ Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q2 │
|
│ Q2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ ● Q1 │
|
│ Q3 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -103,17 +115,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Q3 │
|
│ ● Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q2 │
|
│ Q2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q1 │
|
│ Q3 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -123,17 +138,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ Q3 │
|
│ Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q2 │
|
│ Q2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ ● Q1 │
|
│ Q3 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -146,8 +164,11 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
|
|||||||
│ ● Hello │
|
│ ● Hello │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -158,16 +179,14 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
|
|||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● 1 │
|
│ ● 1 │
|
||||||
│ 2 │
|
│ 2... │
|
||||||
│ 3 │
|
|
||||||
│ 4 │
|
|
||||||
│ 5 │
|
|
||||||
│ 6 │
|
|
||||||
│ 7 │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -177,8 +196,11 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -188,14 +210,17 @@ exports[`RewindViewer > updates content when conversation changes (background up
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Message 2 │
|
│ ● Message 1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Message 1 │
|
│ Message 2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -208,8 +233,11 @@ exports[`RewindViewer > updates content when conversation changes (background up
|
|||||||
│ ● Message 1 │
|
│ ● Message 1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -219,22 +247,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ Line 1 │
|
│ Line A │
|
||||||
│ Line 2 │
|
│ Line B... │
|
||||||
│ ... last 5 lines hidden ... │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ ● Line A │
|
│ ● Line 1 │
|
||||||
│ Line B │
|
│ Line 2... │
|
||||||
│ Line C │
|
|
||||||
│ Line D │
|
|
||||||
│ Line E │
|
|
||||||
│ Line F │
|
|
||||||
│ Line G │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -244,22 +269,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial-
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Line 1 │
|
│ ● Line A │
|
||||||
│ Line 2 │
|
│ Line B... │
|
||||||
│ Line 3 │
|
|
||||||
│ Line 4 │
|
|
||||||
│ Line 5 │
|
|
||||||
│ Line 6 │
|
|
||||||
│ Line 7 │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Line A │
|
│ Line 1 │
|
||||||
│ Line B │
|
│ Line 2... │
|
||||||
│ ... last 5 lines hidden ... │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render } from '../../test-utils/render.js';
|
import { render } from '../../../test-utils/render.js';
|
||||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
|
||||||
|
|
||||||
describe('PrepareLabel', () => {
|
describe('ExpandableText', () => {
|
||||||
const color = 'white';
|
const color = 'white';
|
||||||
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
|
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
|
||||||
|
|
||||||
it('renders plain label when no match (short label)', () => {
|
it('renders plain label when no match (short label)', () => {
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label="simple command"
|
label="simple command"
|
||||||
userInput=""
|
userInput=""
|
||||||
matchedIndex={undefined}
|
matchedIndex={undefined}
|
||||||
@@ -29,7 +29,7 @@ describe('PrepareLabel', () => {
|
|||||||
it('truncates long label when collapsed and no match', () => {
|
it('truncates long label when collapsed and no match', () => {
|
||||||
const long = 'x'.repeat(MAX_WIDTH + 25);
|
const long = 'x'.repeat(MAX_WIDTH + 25);
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={long}
|
label={long}
|
||||||
userInput=""
|
userInput=""
|
||||||
textColor={color}
|
textColor={color}
|
||||||
@@ -47,7 +47,7 @@ describe('PrepareLabel', () => {
|
|||||||
it('shows full long label when expanded and no match', () => {
|
it('shows full long label when expanded and no match', () => {
|
||||||
const long = 'y'.repeat(MAX_WIDTH + 25);
|
const long = 'y'.repeat(MAX_WIDTH + 25);
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={long}
|
label={long}
|
||||||
userInput=""
|
userInput=""
|
||||||
textColor={color}
|
textColor={color}
|
||||||
@@ -66,7 +66,7 @@ describe('PrepareLabel', () => {
|
|||||||
const userInput = 'commit';
|
const userInput = 'commit';
|
||||||
const matchedIndex = label.indexOf(userInput);
|
const matchedIndex = label.indexOf(userInput);
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={label}
|
label={label}
|
||||||
userInput={userInput}
|
userInput={userInput}
|
||||||
matchedIndex={matchedIndex}
|
matchedIndex={matchedIndex}
|
||||||
@@ -86,7 +86,7 @@ describe('PrepareLabel', () => {
|
|||||||
const label = prefix + core + suffix;
|
const label = prefix + core + suffix;
|
||||||
const matchedIndex = prefix.length;
|
const matchedIndex = prefix.length;
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={label}
|
label={label}
|
||||||
userInput={core}
|
userInput={core}
|
||||||
matchedIndex={matchedIndex}
|
matchedIndex={matchedIndex}
|
||||||
@@ -111,7 +111,7 @@ describe('PrepareLabel', () => {
|
|||||||
const label = prefix + core + suffix;
|
const label = prefix + core + suffix;
|
||||||
const matchedIndex = prefix.length;
|
const matchedIndex = prefix.length;
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={label}
|
label={label}
|
||||||
userInput={core}
|
userInput={core}
|
||||||
matchedIndex={matchedIndex}
|
matchedIndex={matchedIndex}
|
||||||
@@ -128,4 +128,24 @@ describe('PrepareLabel', () => {
|
|||||||
expect(out).toMatchSnapshot();
|
expect(out).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('respects custom maxWidth', () => {
|
||||||
|
const customWidth = 50;
|
||||||
|
const long = 'z'.repeat(100);
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<ExpandableText
|
||||||
|
label={long}
|
||||||
|
userInput=""
|
||||||
|
textColor={color}
|
||||||
|
isExpanded={false}
|
||||||
|
maxWidth={customWidth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const out = lastFrame();
|
||||||
|
const f = flat(out);
|
||||||
|
expect(f.endsWith('...')).toBe(true);
|
||||||
|
expect(f.length).toBe(customWidth + 3);
|
||||||
|
expect(out).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,29 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
|
export const MAX_WIDTH = 150;
|
||||||
|
|
||||||
export interface PrepareLabelProps {
|
export interface ExpandableTextProps {
|
||||||
label: string;
|
label: string;
|
||||||
matchedIndex?: number;
|
matchedIndex?: number;
|
||||||
userInput: string;
|
userInput?: string;
|
||||||
textColor: string;
|
textColor?: string;
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxLines?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
const _ExpandableText: React.FC<ExpandableTextProps> = ({
|
||||||
label,
|
label,
|
||||||
matchedIndex,
|
matchedIndex,
|
||||||
userInput,
|
userInput = '',
|
||||||
textColor,
|
textColor = theme.text.primary,
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
|
maxWidth = MAX_WIDTH,
|
||||||
|
maxLines,
|
||||||
}) => {
|
}) => {
|
||||||
const hasMatch =
|
const hasMatch =
|
||||||
matchedIndex !== undefined &&
|
matchedIndex !== undefined &&
|
||||||
@@ -33,11 +37,27 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
|||||||
|
|
||||||
// Render the plain label if there's no match
|
// Render the plain label if there's no match
|
||||||
if (!hasMatch) {
|
if (!hasMatch) {
|
||||||
const display = isExpanded
|
let display = label;
|
||||||
? label
|
|
||||||
: label.length > MAX_WIDTH
|
if (!isExpanded) {
|
||||||
? label.slice(0, MAX_WIDTH) + '...'
|
if (maxLines !== undefined) {
|
||||||
: label;
|
const lines = label.split('\n');
|
||||||
|
// 1. Truncate by logical lines
|
||||||
|
let truncated = lines.slice(0, maxLines).join('\n');
|
||||||
|
const hasMoreLines = lines.length > maxLines;
|
||||||
|
|
||||||
|
// 2. Truncate by characters (visual approximation) to prevent massive wrapping
|
||||||
|
if (truncated.length > maxWidth) {
|
||||||
|
truncated = truncated.slice(0, maxWidth) + '...';
|
||||||
|
} else if (hasMoreLines) {
|
||||||
|
truncated += '...';
|
||||||
|
}
|
||||||
|
display = truncated;
|
||||||
|
} else if (label.length > maxWidth) {
|
||||||
|
display = label.slice(0, maxWidth) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text wrap="wrap" color={textColor}>
|
<Text wrap="wrap" color={textColor}>
|
||||||
{display}
|
{display}
|
||||||
@@ -51,18 +71,18 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
|||||||
let after = '';
|
let after = '';
|
||||||
|
|
||||||
// Case 1: Show the full string if it's expanded or already fits
|
// Case 1: Show the full string if it's expanded or already fits
|
||||||
if (isExpanded || label.length <= MAX_WIDTH) {
|
if (isExpanded || label.length <= maxWidth) {
|
||||||
before = label.slice(0, matchedIndex);
|
before = label.slice(0, matchedIndex);
|
||||||
match = label.slice(matchedIndex, matchedIndex + matchLength);
|
match = label.slice(matchedIndex, matchedIndex + matchLength);
|
||||||
after = label.slice(matchedIndex + matchLength);
|
after = label.slice(matchedIndex + matchLength);
|
||||||
}
|
}
|
||||||
// Case 2: The match itself is too long, so we only show a truncated portion of the match
|
// Case 2: The match itself is too long, so we only show a truncated portion of the match
|
||||||
else if (matchLength >= MAX_WIDTH) {
|
else if (matchLength >= maxWidth) {
|
||||||
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
|
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
|
||||||
}
|
}
|
||||||
// Case 3: Truncate the string to create a window around the match
|
// Case 3: Truncate the string to create a window around the match
|
||||||
else {
|
else {
|
||||||
const contextSpace = MAX_WIDTH - matchLength;
|
const contextSpace = maxWidth - matchLength;
|
||||||
const beforeSpace = Math.floor(contextSpace / 2);
|
const beforeSpace = Math.floor(contextSpace / 2);
|
||||||
const afterSpace = Math.ceil(contextSpace / 2);
|
const afterSpace = Math.ceil(contextSpace / 2);
|
||||||
|
|
||||||
@@ -113,4 +133,4 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PrepareLabel = React.memo(_PrepareLabel);
|
export const ExpandableText = React.memo(_ExpandableText);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > creates centered window around match when collapsed 1`] = `
|
||||||
|
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||||
|
components//and/then/some/more/components//and/..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > shows full long label when expanded and no match 1`] = `
|
||||||
|
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||||
|
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > truncates long label when collapsed and no match 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > truncates match itself when match is very long 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
|
||||||
|
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||||
|
components//and/then/some/more/components//and/..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
|
||||||
|
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||||
|
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
@@ -18,12 +18,17 @@ import {
|
|||||||
isNodeError,
|
isNodeError,
|
||||||
unescapePath,
|
unescapePath,
|
||||||
ReadManyFilesTool,
|
ReadManyFilesTool,
|
||||||
|
REFERENCE_CONTENT_START,
|
||||||
|
REFERENCE_CONTENT_END,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
|
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
|
||||||
import { ToolCallStatus } from '../types.js';
|
import { ToolCallStatus } from '../types.js';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
|
||||||
|
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
|
||||||
|
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
||||||
|
|
||||||
interface HandleAtCommandParams {
|
interface HandleAtCommandParams {
|
||||||
query: string;
|
query: string;
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -499,10 +504,17 @@ export async function handleAtCommand({
|
|||||||
const resourceResults = await Promise.all(resourcePromises);
|
const resourceResults = await Promise.all(resourcePromises);
|
||||||
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
|
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
|
||||||
let resourceErrorOccurred = false;
|
let resourceErrorOccurred = false;
|
||||||
|
let hasAddedReferenceHeader = false;
|
||||||
|
|
||||||
for (const result of resourceResults) {
|
for (const result of resourceResults) {
|
||||||
resourceReadDisplays.push(result.display);
|
resourceReadDisplays.push(result.display);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (!hasAddedReferenceHeader) {
|
||||||
|
processedQueryParts.push({
|
||||||
|
text: REF_CONTENT_HEADER,
|
||||||
|
});
|
||||||
|
hasAddedReferenceHeader = true;
|
||||||
|
}
|
||||||
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
|
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
|
||||||
processedQueryParts.push(...result.parts);
|
processedQueryParts.push(...result.parts);
|
||||||
} else {
|
} else {
|
||||||
@@ -540,6 +552,9 @@ export async function handleAtCommand({
|
|||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (hasAddedReferenceHeader) {
|
||||||
|
processedQueryParts.push({ text: REF_CONTENT_FOOTER });
|
||||||
|
}
|
||||||
return { processedQuery: processedQueryParts };
|
return { processedQuery: processedQueryParts };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,9 +585,12 @@ export async function handleAtCommand({
|
|||||||
|
|
||||||
if (Array.isArray(result.llmContent)) {
|
if (Array.isArray(result.llmContent)) {
|
||||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||||
processedQueryParts.push({
|
if (!hasAddedReferenceHeader) {
|
||||||
text: '\n--- Content from referenced files ---',
|
processedQueryParts.push({
|
||||||
});
|
text: REF_CONTENT_HEADER,
|
||||||
|
});
|
||||||
|
hasAddedReferenceHeader = true;
|
||||||
|
}
|
||||||
for (const part of result.llmContent) {
|
for (const part of result.llmContent) {
|
||||||
if (typeof part === 'string') {
|
if (typeof part === 'string') {
|
||||||
const match = fileContentRegex.exec(part);
|
const match = fileContentRegex.exec(part);
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
toggleDebugProfiler: vi.fn(),
|
toggleDebugProfiler: vi.fn(),
|
||||||
dispatchExtensionStateUpdate: vi.fn(),
|
dispatchExtensionStateUpdate: vi.fn(),
|
||||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||||
|
setText: vi.fn(),
|
||||||
},
|
},
|
||||||
new Map(), // extensionsUpdateState
|
new Map(), // extensionsUpdateState
|
||||||
true, // isConfigInitialized
|
true, // isConfigInitialized
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ interface SlashCommandProcessorActions {
|
|||||||
toggleDebugProfiler: () => void;
|
toggleDebugProfiler: () => void;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +211,12 @@ export const useSlashCommandProcessor = (
|
|||||||
refreshStatic();
|
refreshStatic();
|
||||||
setBannerVisible(false);
|
setBannerVisible(false);
|
||||||
},
|
},
|
||||||
loadHistory,
|
loadHistory: (history, postLoadInput) => {
|
||||||
|
loadHistory(history);
|
||||||
|
if (postLoadInput !== undefined) {
|
||||||
|
actions.setText(postLoadInput);
|
||||||
|
}
|
||||||
|
},
|
||||||
setDebugMessage: actions.setDebugMessage,
|
setDebugMessage: actions.setDebugMessage,
|
||||||
pendingItem,
|
pendingItem,
|
||||||
setPendingItem,
|
setPendingItem,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
REFERENCE_CONTENT_START,
|
||||||
|
REFERENCE_CONTENT_END,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
export const formatMemoryUsage = (bytes: number): string => {
|
export const formatMemoryUsage = (bytes: number): string => {
|
||||||
const gb = bytes / (1024 * 1024 * 1024);
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
if (bytes < 1024 * 1024) {
|
if (bytes < 1024 * 1024) {
|
||||||
@@ -76,12 +81,9 @@ export const formatTimeAgo = (date: string | number | Date): string => {
|
|||||||
return `${formatDuration(diffMs)} ago`;
|
return `${formatDuration(diffMs)} ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
|
|
||||||
const REFERENCE_CONTENT_END = '--- End of content ---';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes content bounded by reference content markers from the given text.
|
* Removes content bounded by reference content markers from the given text.
|
||||||
* The markers are "--- Content from referenced files ---" and "--- End of content ---".
|
* The markers are "${REFERENCE_CONTENT_START}" and "${REFERENCE_CONTENT_END}".
|
||||||
*
|
*
|
||||||
* @param text The input text containing potential reference blocks.
|
* @param text The input text containing potential reference blocks.
|
||||||
* @returns The text with reference blocks removed and trimmed.
|
* @returns The text with reference blocks removed and trimmed.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
debugLogger,
|
debugLogger,
|
||||||
ReadManyFilesTool,
|
ReadManyFilesTool,
|
||||||
|
REFERENCE_CONTENT_START,
|
||||||
resolveModel,
|
resolveModel,
|
||||||
createWorkingStdio,
|
createWorkingStdio,
|
||||||
startupProfiler,
|
startupProfiler,
|
||||||
@@ -817,7 +818,7 @@ export class Session {
|
|||||||
if (Array.isArray(result.llmContent)) {
|
if (Array.isArray(result.llmContent)) {
|
||||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||||
processedQueryParts.push({
|
processedQueryParts.push({
|
||||||
text: '\n--- Content from referenced files ---',
|
text: `\n${REFERENCE_CONTENT_START}`,
|
||||||
});
|
});
|
||||||
for (const part of result.llmContent) {
|
for (const part of result.llmContent) {
|
||||||
if (typeof part === 'string') {
|
if (typeof part === 'string') {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export * from './utils/checkpointUtils.js';
|
|||||||
export * from './utils/secure-browser-launcher.js';
|
export * from './utils/secure-browser-launcher.js';
|
||||||
export * from './utils/apiConversionUtils.js';
|
export * from './utils/apiConversionUtils.js';
|
||||||
export * from './utils/channel.js';
|
export * from './utils/channel.js';
|
||||||
|
export * from './utils/constants.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import { FileOperationEvent } from '../telemetry/types.js';
|
|||||||
import { ToolErrorType } from './tool-error.js';
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import { READ_MANY_FILES_TOOL_NAME } from './tool-names.js';
|
import { READ_MANY_FILES_TOOL_NAME } from './tool-names.js';
|
||||||
|
|
||||||
|
import { REFERENCE_CONTENT_END } from '../utils/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for the ReadManyFilesTool.
|
* Parameters for the ReadManyFilesTool.
|
||||||
*/
|
*/
|
||||||
@@ -98,7 +100,7 @@ function getDefaultExcludes(config?: Config): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
|
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
|
||||||
const DEFAULT_OUTPUT_TERMINATOR = '\n--- End of content ---';
|
const DEFAULT_OUTPUT_TERMINATOR = `\n${REFERENCE_CONTENT_END}`;
|
||||||
|
|
||||||
class ReadManyFilesToolInvocation extends BaseToolInvocation<
|
class ReadManyFilesToolInvocation extends BaseToolInvocation<
|
||||||
ReadManyFilesParams,
|
ReadManyFilesParams,
|
||||||
@@ -517,7 +519,7 @@ This tool is useful when you need to understand or analyze a collection of files
|
|||||||
- Gathering context from multiple configuration files.
|
- Gathering context from multiple configuration files.
|
||||||
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
|
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
|
||||||
|
|
||||||
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '--- End of content ---' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
|
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '${REFERENCE_CONTENT_END}' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
|
||||||
Kind.Read,
|
Kind.Read,
|
||||||
parameterSchema,
|
parameterSchema,
|
||||||
messageBus,
|
messageBus,
|
||||||
|
|||||||
8
packages/core/src/utils/constants.ts
Normal file
8
packages/core/src/utils/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
|
||||||
|
export const REFERENCE_CONTENT_END = '--- End of content ---';
|
||||||
@@ -27,6 +27,7 @@ const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md'];
|
|||||||
const KEY_NAME_OVERRIDES: Record<string, string> = {
|
const KEY_NAME_OVERRIDES: Record<string, string> = {
|
||||||
return: 'Enter',
|
return: 'Enter',
|
||||||
escape: 'Esc',
|
escape: 'Esc',
|
||||||
|
'double escape': 'Double Esc',
|
||||||
tab: 'Tab',
|
tab: 'Tab',
|
||||||
backspace: 'Backspace',
|
backspace: 'Backspace',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
|
|||||||
Reference in New Issue
Block a user