diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index b57b2c7a68..c8750b4902 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -48,6 +48,7 @@ export enum Command { SHOW_ERROR_DETAILS = 'showErrorDetails', TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions', TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', + TOGGLE_MARKDOWN = 'toggleMarkdown', QUIT = 'quit', EXIT = 'exit', SHOW_MORE_LINES = 'showMoreLines', @@ -158,6 +159,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], + [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], [Command.QUIT]: [{ key: 'c', ctrl: true }], [Command.EXIT]: [{ key: 'd', ctrl: true }], [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 5002e34a29..59874bfceb 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -11,6 +11,7 @@ import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; +import { StreamingState } from '../ui/types.js'; import { ConfigContext } from '../ui/contexts/ConfigContext.js'; import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; @@ -59,6 +60,8 @@ export const createMockSettings = ( // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. const baseMockUiState = { + renderMarkdown: true, + streamingState: StreamingState.Idle, mainAreaWidth: 100, terminalWidth: 120, }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 14101c352f..6a97507042 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -793,6 +793,7 @@ Logging in with Google... Please restart Gemini CLI to continue. const [showErrorDetails, setShowErrorDetails] = useState(false); const [showToolDescriptions, setShowToolDescriptions] = useState(false); + const [renderMarkdown, setRenderMarkdown] = useState(true); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); @@ -973,6 +974,13 @@ Logging in with Google... Please restart Gemini CLI to continue. if (Object.keys(mcpServers || {}).length > 0) { handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); } + } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { + setRenderMarkdown((prev) => { + const newValue = !prev; + // Force re-render of static content + refreshStatic(); + return newValue; + }); } else if ( keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) && config.getIdeMode() && @@ -1011,6 +1019,7 @@ Logging in with Google... Please restart Gemini CLI to continue. activePtyId, embeddedShellFocused, settings.merged.general?.debugKeystrokeLogging, + refreshStatic, ], ); @@ -1139,6 +1148,7 @@ Logging in with Google... Please restart Gemini CLI to continue. filteredConsoleMessages, ideContextState, showToolDescriptions, + renderMarkdown, ctrlCPressedOnce, ctrlDPressedOnce, showEscapePrompt, @@ -1221,6 +1231,7 @@ Logging in with Google... Please restart Gemini CLI to continue. filteredConsoleMessages, ideContextState, showToolDescriptions, + renderMarkdown, ctrlCPressedOnce, ctrlDPressedOnce, showEscapePrompt, diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 6d1f5372c5..f0558b53ca 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -112,6 +112,7 @@ const createMockUIState = (overrides: Partial = {}): 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', () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 0cd7f0ad98..af68096349 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -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 = () => { )} {uiState.shellModeActive && } + {!uiState.renderMarkdown && } diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx new file mode 100644 index 0000000000..c47b35f244 --- /dev/null +++ b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx @@ -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 ( + + + raw markdown mode + ({modKey} to toggle) + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.test.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.test.tsx new file mode 100644 index 0000000000..06a551554a --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiMessage.test.tsx @@ -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(' - 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( + , + { + uiState: { renderMarkdown, streamingState: StreamingState.Idle }, + }, + ); + expect(lastFrame()).toMatchSnapshot(); + }, + ); + + it.each([{ renderMarkdown: true }, { renderMarkdown: false }])( + 'renders pending state with renderMarkdown=$renderMarkdown', + ({ renderMarkdown }) => { + const { lastFrame } = renderWithProviders( + , + { + uiState: { renderMarkdown, streamingState: StreamingState.Idle }, + }, + ); + expect(lastFrame()).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 389b5ac151..1426ea73e9 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -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 = ({ availableTerminalHeight, terminalWidth, }) => { + const { renderMarkdown } = useUIState(); const prefix = '✦ '; const prefixWidth = prefix.length; @@ -39,6 +41,7 @@ export const GeminiMessage: React.FC = ({ isPending={isPending} availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} + renderMarkdown={renderMarkdown} /> diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx index 05bdb75cf5..4908ea1780 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx @@ -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 = ({ availableTerminalHeight, terminalWidth, }) => { + const { renderMarkdown } = useUIState(); const originalPrefix = '✦ '; const prefixWidth = originalPrefix.length; @@ -37,6 +39,7 @@ export const GeminiMessageContent: React.FC = ({ isPending={isPending} availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} + renderMarkdown={renderMarkdown} /> ); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 3f04404e79..ce339deee4 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -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( {ui} , diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index a65ac2f9ec..93d4528a7e 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -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 = ({ 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 = ({ isPending={false} availableTerminalHeight={availableHeight} terminalWidth={childWidth} + renderMarkdown={renderMarkdown} /> ) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? ( diff --git a/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx new file mode 100644 index 0000000000..27c4b88a23 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx @@ -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(' - 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( + + + , + { + uiState: { renderMarkdown, streamingState: StreamingState.Idle }, + }, + ); + expect(lastFrame()).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/GeminiMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/GeminiMessage.test.tsx.snap new file mode 100644 index 0000000000..d2c032a953 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/GeminiMessage.test.tsx.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=false 1`] = ` +"✦ Test **bold** and \`code\` markdown + + \`\`\`javascript + const x = 1; + \`\`\`" +`; + +exports[` - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=true 1`] = ` +"✦ Test bold and code markdown + + 1 const x = 1;" +`; + +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = ` +"✦ Test **bold** and \`code\` markdown + + \`\`\`javascript + const x = 1; + \`\`\`" +`; + +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = ` +"✦ Test bold and code markdown + + 1 const x = 1;" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap new file mode 100644 index 0000000000..94725a99cc --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` - 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[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = ` +" ✓ test-tool A tool for testing + + Test bold and code markdown" +`; diff --git a/packages/cli/src/ui/components/views/ToolsList.test.tsx b/packages/cli/src/ui/components/views/ToolsList.test.tsx index 553ba3d872..62fad640f9 100644 --- a/packages/cli/src/ui/components/views/ToolsList.test.tsx +++ b/packages/cli/src/ui/components/views/ToolsList.test.tsx @@ -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('', () => { it('renders correctly with descriptions', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( ', () => { }); it('renders correctly without descriptions', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( ', () => { }); it('renders correctly with no tools', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 76c0de298f..b0c87b779b 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -82,6 +82,7 @@ export interface UIState { filteredConsoleMessages: ConsoleMessageItem[]; ideContextState: IdeContext | undefined; showToolDescriptions: boolean; + renderMarkdown: boolean; ctrlCPressedOnce: boolean; ctrlDPressedOnce: boolean; showEscapePrompt: boolean; diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 690a5cee49..c0e04af7ec 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -55,6 +55,7 @@ describe('keyMatchers', () => { key.ctrl && key.name === 't', [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => key.ctrl && key.name === 'g', + [Command.TOGGLE_MARKDOWN]: (key: Key) => key.meta && key.name === 'm', [Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c', [Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd', [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', @@ -225,6 +226,11 @@ describe('keyMatchers', () => { positive: [createKey('g', { ctrl: true })], negative: [createKey('g'), createKey('t', { ctrl: true })], }, + { + command: Command.TOGGLE_MARKDOWN, + positive: [createKey('m', { meta: true })], + negative: [createKey('m'), createKey('m', { shift: true })], + }, { command: Command.QUIT, positive: [createKey('c', { ctrl: true })], diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index 644248fd05..021227b89b 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -132,10 +132,13 @@ export function colorizeCode( maxWidth?: number, theme?: Theme, settings?: LoadedSettings, + hideLineNumbers?: boolean, ): React.ReactNode { const codeToHighlight = code.replace(/\n$/, ''); const activeTheme = theme || themeManager.getActiveTheme(); - const showLineNumbers = settings?.merged.ui?.showLineNumbers ?? true; + const showLineNumbers = hideLineNumbers + ? false + : (settings?.merged.ui?.showLineNumbers ?? true); try { // Render the HAST tree using the adapted theme diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index da6bf21aaf..7a95157521 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -17,6 +17,7 @@ interface MarkdownDisplayProps { isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; + renderMarkdown?: boolean; } // Constants for Markdown parsing and rendering @@ -31,9 +32,31 @@ const MarkdownDisplayInternal: React.FC = ({ isPending, availableTerminalHeight, terminalWidth, + renderMarkdown = true, }) => { + const settings = useSettings(); + if (!text) return <>; + // Raw markdown mode - display syntax-highlighted markdown without rendering + if (!renderMarkdown) { + // Hide line numbers in raw markdown mode as they are confusing due to chunked output + const colorizedMarkdown = colorizeCode( + text, + 'markdown', + availableTerminalHeight, + terminalWidth - CODE_BLOCK_PREFIX_PADDING, + undefined, + settings, + true, // hideLineNumbers + ); + return ( + + {colorizedMarkdown} + + ); + } + const lines = text.split(/\r?\n/); const headerRegex = /^ *(#{1,4}) +(.*)/; const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/;