feat: Add markdown toggle (alt+m) to switch between rendered and raw… (#10383)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Srivats Jayaram
2025-10-16 11:23:36 -07:00
committed by GitHub
parent 05930d5e25
commit 6ded45e5d2
19 changed files with 245 additions and 7 deletions

View File

@@ -112,6 +112,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
showToolDescriptions: false,
renderMarkdown: true,
filteredConsoleMessages: [],
sessionStats: {
lastPromptTokenCount: 0,
@@ -403,6 +404,26 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ShellModeIndicator');
});
it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
const uiState = createMockUIState({
renderMarkdown: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('raw markdown mode');
});
it('does not show RawMarkdownIndicator when renderMarkdown is true', () => {
const uiState = createMockUIState({
renderMarkdown: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('raw markdown mode');
});
});
describe('Error Details Display', () => {

View File

@@ -10,6 +10,7 @@ import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
@@ -110,6 +111,7 @@ export const Composer = () => {
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
)}
{uiState.shellModeActive && <ShellModeIndicator />}
{!uiState.renderMarkdown && <RawMarkdownIndicator />}
</Box>
</Box>

View File

@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
export const RawMarkdownIndicator: React.FC = () => {
const modKey = process.platform === 'darwin' ? 'option+m' : 'alt+m';
return (
<Box>
<Text>
raw markdown mode
<Text color={theme.text.secondary}> ({modKey} to toggle) </Text>
</Text>
</Box>
);
};

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GeminiMessage } from './GeminiMessage.js';
import { StreamingState } from '../../types.js';
import { renderWithProviders } from '../../../test-utils/render.js';
describe('<GeminiMessage /> - Raw Markdown Display Snapshots', () => {
const baseProps = {
text: 'Test **bold** and `code` markdown\n\n```javascript\nconst x = 1;\n```',
isPending: false,
terminalWidth: 80,
};
it.each([
{ renderMarkdown: true, description: '(default)' },
{
renderMarkdown: false,
description: '(raw markdown with syntax highlighting, no line numbers)',
},
])(
'renders with renderMarkdown=$renderMarkdown $description',
({ renderMarkdown }) => {
const { lastFrame } = renderWithProviders(
<GeminiMessage {...baseProps} />,
{
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
},
);
expect(lastFrame()).toMatchSnapshot();
},
);
it.each([{ renderMarkdown: true }, { renderMarkdown: false }])(
'renders pending state with renderMarkdown=$renderMarkdown',
({ renderMarkdown }) => {
const { lastFrame } = renderWithProviders(
<GeminiMessage {...baseProps} isPending={true} />,
{
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
},
);
expect(lastFrame()).toMatchSnapshot();
},
);
});

View File

@@ -9,6 +9,7 @@ import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
interface GeminiMessageProps {
text: string;
@@ -23,6 +24,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
availableTerminalHeight,
terminalWidth,
}) => {
const { renderMarkdown } = useUIState();
const prefix = '✦ ';
const prefixWidth = prefix.length;
@@ -39,6 +41,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>
</Box>
</Box>

View File

@@ -7,6 +7,7 @@
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { useUIState } from '../../contexts/UIStateContext.js';
interface GeminiMessageContentProps {
text: string;
@@ -27,6 +28,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
availableTerminalHeight,
terminalWidth,
}) => {
const { renderMarkdown } = useUIState();
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
@@ -37,6 +39,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>
</Box>
);

View File

@@ -5,13 +5,13 @@
*/
import React from 'react';
import { render } from 'ink-testing-library';
import type { ToolMessageProps } from './ToolMessage.js';
import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import type { AnsiOutput } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
@@ -72,7 +72,7 @@ const renderWithContext = (
streamingState: StreamingState,
) => {
const contextValue: StreamingState = streamingState;
return render(
return renderWithProviders(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,

View File

@@ -21,6 +21,7 @@ import {
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import type { AnsiOutput, Config } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -56,6 +57,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
}) => {
const { renderMarkdown } = useUIState();
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
@@ -149,6 +151,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
/>
</Box>
) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? (

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolMessageProps } from './ToolMessage.js';
import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { renderWithProviders } from '../../../test-utils/render.js';
describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
const baseProps: ToolMessageProps = {
callId: 'tool-123',
name: 'test-tool',
description: 'A tool for testing',
resultDisplay: 'Test **bold** and `code` markdown',
status: ToolCallStatus.Success,
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
};
it.each([
{ renderMarkdown: true, description: '(default)' },
{
renderMarkdown: false,
description: '(raw markdown with syntax highlighting, no line numbers)',
},
])(
'renders with renderMarkdown=$renderMarkdown $description',
({ renderMarkdown }) => {
const { lastFrame } = renderWithProviders(
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage {...baseProps} />
</StreamingContext.Provider>,
{
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
},
);
expect(lastFrame()).toMatchSnapshot();
},
);
});

View File

@@ -0,0 +1,29 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=false 1`] = `
"✦ Test **bold** and \`code\` markdown
\`\`\`javascript
const x = 1;
\`\`\`"
`;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=true 1`] = `
"✦ Test bold and code markdown
1 const x = 1;"
`;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = `
"✦ Test **bold** and \`code\` markdown
\`\`\`javascript
const x = 1;
\`\`\`"
`;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
"✦ Test bold and code markdown
1 const x = 1;"
`;

View File

@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = `
" ✓ test-tool A tool for testing
Test **bold** and \`code\` markdown"
`;
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
" ✓ test-tool A tool for testing
Test bold and code markdown"
`;

View File

@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { ToolsList } from './ToolsList.js';
import { type ToolDefinition } from '../../types.js';
import { renderWithProviders } from '../../../test-utils/render.js';
const mockTools: ToolDefinition[] = [
{
@@ -32,7 +32,7 @@ const mockTools: ToolDefinition[] = [
describe('<ToolsList />', () => {
it('renders correctly with descriptions', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolsList
tools={mockTools}
showDescriptions={true}
@@ -43,7 +43,7 @@ describe('<ToolsList />', () => {
});
it('renders correctly without descriptions', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolsList
tools={mockTools}
showDescriptions={false}
@@ -54,7 +54,7 @@ describe('<ToolsList />', () => {
});
it('renders correctly with no tools', () => {
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<ToolsList tools={[]} showDescriptions={true} terminalWidth={40} />,
);
expect(lastFrame()).toMatchSnapshot();