diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 74c1ea5d..4921c93e 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -5,8 +5,21 @@ */ import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { Box, DOMElement, measureElement, Static, Text } from 'ink'; -import { StreamingState, type HistoryItem } from './types.js'; +import { + Box, + DOMElement, + measureElement, + Static, + Text, + useInput, + type Key as InkKeyType, +} from 'ink'; +import { + StreamingState, + type HistoryItem, + ConsoleMessageItem, + MessageType, +} from './types.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; @@ -25,11 +38,11 @@ import { Help } from './components/Help.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import { LoadedSettings } from '../config/settings.js'; import { Tips } from './components/Tips.js'; -import { ConsoleOutput } from './components/ConsolePatcher.js'; +import { useConsolePatcher } from './components/ConsolePatcher.js'; +import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js'; import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; import { useHistory } from './hooks/useHistoryManager.js'; import process from 'node:process'; -import { MessageType } from './types.js'; import { getErrorMessage, type Config } from '@gemini-code/server'; import { useLogger } from './hooks/useLogger.js'; @@ -61,6 +74,32 @@ export const App = ({ const [corgiMode, setCorgiMode] = useState(false); const [shellModeActive, setShellModeActive] = useState(false); + const [consoleMessages, setConsoleMessages] = useState( + [], + ); + const [showErrorDetails, setShowErrorDetails] = useState(false); + + const errorCount = useMemo( + () => consoleMessages.filter((msg) => msg.type === 'error').length, + [consoleMessages], + ); + useInput((input: string, key: InkKeyType) => { + // Check for Ctrl+D key press + if (key.ctrl && (input === 'd' || input === 'D')) { + setShowErrorDetails((prev) => !prev); + refreshStatic(); + } + }); + + const handleNewMessage = useCallback((message: ConsoleMessageItem) => { + setConsoleMessages((prevMessages) => [...prevMessages, message]); + }, []); + + useConsolePatcher({ + onNewMessage: handleNewMessage, + debugMode: config.getDebugMode(), + }); + const toggleCorgiMode = useCallback(() => { setCorgiMode((prev) => !prev); }, []); @@ -72,7 +111,6 @@ export const App = ({ handleThemeHighlight, } = useThemeCommand(settings, setThemeError); - // useEffect to initialize geminiMdFileCount from config when config is ready useEffect(() => { if (config) { setGeminiMdFileCount(config.getGeminiMdFileCount()); @@ -186,12 +224,11 @@ export const App = ({ const handleClearScreen = useCallback(() => { clearItems(); + setConsoleMessages([]); console.clear(); refreshStatic(); }, [clearItems, refreshStatic]); - // --- Render Logic --- - const { rows: terminalHeight } = useTerminalSize(); const mainControlsRef = useRef(null); const pendingHistoryItemRef = useRef(null); @@ -201,7 +238,7 @@ export const App = ({ const fullFooterMeasurement = measureElement(mainControlsRef.current); setFooterHeight(fullFooterMeasurement.height); } - }, [terminalHeight]); + }, [terminalHeight, consoleMessages, showErrorDetails]); const availableTerminalHeight = useMemo(() => { const staticExtraHeight = /* margins and padding */ 3; @@ -232,6 +269,13 @@ export const App = ({ } }, [streamingState, refreshStatic, staticNeedsRefresh]); + const filteredConsoleMessages = useMemo(() => { + if (config.getDebugMode()) { + return consoleMessages; + } + return consoleMessages.filter((msg) => msg.type !== 'debug'); + }, [consoleMessages, config]); + return ( {/* @@ -339,6 +383,11 @@ export const App = ({ {shellModeActive && } + + {showErrorDetails && ( + + )} + {isInputActive && ( - ); diff --git a/packages/cli/src/ui/components/ConsolePatcher.tsx b/packages/cli/src/ui/components/ConsolePatcher.tsx index 366aef43..240a32e5 100644 --- a/packages/cli/src/ui/components/ConsolePatcher.tsx +++ b/packages/cli/src/ui/components/ConsolePatcher.tsx @@ -4,27 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, Key } from 'react'; -import { Box, Text } from 'ink'; +import { useEffect } from 'react'; import util from 'util'; +import { ConsoleMessageItem } from '../types.js'; -interface ConsoleMessage { - id: Key; - type: 'log' | 'warn' | 'error' | 'debug'; - content: string; -} - -// Using a module-level counter for unique IDs. -// This ensures IDs are unique across messages. -let messageIdCounter = 0; - -interface ConsoleOutputProps { +interface UseConsolePatcherParams { + onNewMessage: (message: Omit) => void; debugMode: boolean; } -export const ConsoleOutput: React.FC = ({ debugMode }) => { - const [messages, setMessages] = useState([]); - +export const useConsolePatcher = ({ + onNewMessage, + debugMode, +}: UseConsolePatcherParams): void => { useEffect(() => { const originalConsoleLog = console.log; const originalConsoleWarn = console.warn; @@ -32,25 +24,30 @@ export const ConsoleOutput: React.FC = ({ debugMode }) => { const originalConsoleDebug = console.debug; const formatArgs = (args: unknown[]): string => util.format(...args); - const addMessage = ( - type: 'log' | 'warn' | 'error' | 'debug', - args: unknown[], - ) => { - setMessages((prevMessages) => [ - ...prevMessages, - { - id: `console-msg-${messageIdCounter++}`, - type, - content: formatArgs(args), - }, - ]); - }; - // It's patching time - console.log = (...args: unknown[]) => addMessage('log', args); - console.warn = (...args: unknown[]) => addMessage('warn', args); - console.error = (...args: unknown[]) => addMessage('error', args); - console.debug = (...args: unknown[]) => addMessage('debug', args); + const patchConsoleMethod = + ( + type: 'log' | 'warn' | 'error' | 'debug', + originalMethod: (...args: unknown[]) => void, + ) => + (...args: unknown[]) => { + if (debugMode) { + originalMethod.apply(console, args); + } + + // Then, if it's not a debug message or debugMode is on, pass to onNewMessage + if (type !== 'debug' || debugMode) { + onNewMessage({ + type, + content: formatArgs(args), + }); + } + }; + + console.log = patchConsoleMethod('log', originalConsoleLog); + console.warn = patchConsoleMethod('warn', originalConsoleWarn); + console.error = patchConsoleMethod('error', originalConsoleError); + console.debug = patchConsoleMethod('debug', originalConsoleDebug); return () => { console.log = originalConsoleLog; @@ -58,46 +55,5 @@ export const ConsoleOutput: React.FC = ({ debugMode }) => { console.error = originalConsoleError; console.debug = originalConsoleDebug; }; - }, []); - - return ( - - {messages.map((msg) => { - if (msg.type === 'debug' && !debugMode) { - return null; - } - - const textProps: { color?: string } = {}; - let prefix = ''; - - switch (msg.type) { - case 'warn': - textProps.color = 'yellow'; - prefix = 'WARN: '; - break; - case 'error': - textProps.color = 'red'; - prefix = 'ERROR: '; - break; - case 'debug': - textProps.color = 'gray'; - prefix = 'DEBUG: '; - break; - case 'log': - default: - prefix = 'LOG: '; - break; - } - - return ( - - - {prefix} - {msg.content} - - - ); - })} - - ); + }, [onNewMessage, debugMode]); }; diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx new file mode 100644 index 00000000..b944f409 --- /dev/null +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../colors.js'; + +interface ConsoleSummaryDisplayProps { + errorCount: number; + // logCount is not currently in the plan to be displayed in summary +} + +export const ConsoleSummaryDisplay: React.FC = ({ + errorCount, +}) => { + if (errorCount === 0) { + return null; + } + + const errorIcon = '\u2716'; // Heavy multiplication x (✖) + + return ( + + {errorCount > 0 && ( + + {errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '} + (CTRL-D for details) + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx new file mode 100644 index 00000000..de4f3f6e --- /dev/null +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../colors.js'; +import { ConsoleMessageItem } from '../types.js'; + +interface DetailedMessagesDisplayProps { + messages: ConsoleMessageItem[]; + // debugMode is not needed here if App.tsx filters debug messages before passing them. + // If DetailedMessagesDisplay should handle filtering, add debugMode prop. +} + +export const DetailedMessagesDisplay: React.FC< + DetailedMessagesDisplayProps +> = ({ messages }) => { + if (messages.length === 0) { + return null; // Don't render anything if there are no messages + } + + return ( + + + + Debug Console{' '} + (CTRL-D to close) + + + {messages.map((msg, index) => { + let textColor = Colors.Foreground; + let icon = '\u2139'; // Information source (ℹ) + + switch (msg.type) { + case 'warn': + textColor = Colors.AccentYellow; + icon = '\u26A0'; // Warning sign (⚠) + break; + case 'error': + textColor = Colors.AccentRed; + icon = '\u2716'; // Heavy multiplication x (✖) + break; + case 'debug': + textColor = Colors.SubtleComment; // Or Colors.Gray + icon = '\u1F50D'; // Left-pointing magnifying glass (????) + break; + case 'log': + default: + // Default textColor and icon are already set + break; + } + + return ( + + {icon} + + {msg.content} + + + ); + })} + + ); +}; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 03e85db1..7f7d058a 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { shortenPath, tildeifyPath, Config } from '@gemini-code/server'; +import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; interface FooterProps { config: Config; @@ -15,6 +16,8 @@ interface FooterProps { debugMessage: string; cliVersion: string; corgiMode: boolean; + errorCount: number; + showErrorDetails: boolean; } export const Footer: React.FC = ({ @@ -23,8 +26,10 @@ export const Footer: React.FC = ({ debugMessage, cliVersion, corgiMode, + errorCount, + showErrorDetails, }) => ( - + {shortenPath(tildeifyPath(config.getTargetDir()), 70)} @@ -56,8 +61,8 @@ export const Footer: React.FC = ({ )} - {/* Right Section: Gemini Label */} - + {/* Right Section: Gemini Label and Console Summary */} + {config.getModel()} | CLI {cliVersion} {corgiMode && ( @@ -70,6 +75,12 @@ export const Footer: React.FC = ({ )} + {!showErrorDetails && errorCount > 0 && ( + + | + + + )} ); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ff7515a5..d660fe16 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -119,3 +119,8 @@ export interface Message { content: string; // Renamed from text for clarity in this context timestamp: Date; // For consistency, though addItem might use its own timestamping } + +export interface ConsoleMessageItem { + type: 'log' | 'warn' | 'error' | 'debug'; + content: string; +}