/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useMemo } from 'react'; import { Box, Text } from 'ink'; import type { ThoughtSummary } from '@google/gemini-cli-core'; import { theme } from '../../semantic-colors.js'; import { normalizeEscapedNewlines } from '../../utils/textUtils.js'; interface ThinkingMessageProps { thought: ThoughtSummary; terminalWidth: number; isFirstThinking?: boolean; isLastThinking?: boolean; } const THINKING_LEFT_PADDING = 1; const VERTICAL_LINE_WIDTH = 1; function splitGraphemes(value: string): string[] { if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme', }); return Array.from(segmenter.segment(value), (segment) => segment.segment); } return Array.from(value); } function normalizeThoughtLines(thought: ThoughtSummary): string[] { const subject = normalizeEscapedNewlines(thought.subject).trim(); const description = normalizeEscapedNewlines(thought.description).trim(); if (!subject && !description) { return []; } if (!subject) { return description .split('\n') .map((line) => line.trim()) .filter(Boolean); } const bodyLines = description .split('\n') .map((line) => line.trim()) .filter(Boolean); return [subject, ...bodyLines]; } function graphemeLength(value: string): number { return splitGraphemes(value).length; } function chunkToWidth(value: string, width: number): string[] { if (width <= 0) { return ['']; } const graphemes = splitGraphemes(value); if (graphemes.length === 0) { return ['']; } const chunks: string[] = []; for (let index = 0; index < graphemes.length; index += width) { chunks.push(graphemes.slice(index, index + width).join('')); } return chunks; } function wrapLineToWidth(line: string, width: number): string[] { if (width <= 0) { return ['']; } const normalized = line.trim(); if (!normalized) { return ['']; } const words = normalized.split(/\s+/); const wrapped: string[] = []; let current = ''; for (const word of words) { const wordChunks = chunkToWidth(word, width); for (const wordChunk of wordChunks) { if (!current) { current = wordChunk; continue; } if (graphemeLength(current) + 1 + graphemeLength(wordChunk) <= width) { current = `${current} ${wordChunk}`; } else { wrapped.push(current); current = wordChunk; } } } if (current) { wrapped.push(current); } return wrapped; } /** * Renders a model's thought as a distinct bubble. * Leverages Ink layout for wrapping and borders. */ export const ThinkingMessage: React.FC = ({ thought, terminalWidth, isFirstThinking, isLastThinking, }) => { const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]); const contentWidth = Math.max( terminalWidth - THINKING_LEFT_PADDING - VERTICAL_LINE_WIDTH - 2, 1, ); const fullSummaryDisplayLines = useMemo( () => fullLines.length > 0 ? wrapLineToWidth(fullLines[0], contentWidth) : [], [fullLines, contentWidth], ); const fullBodyDisplayLines = useMemo( () => fullLines.slice(1).flatMap((line) => wrapLineToWidth(line, contentWidth)), [fullLines, contentWidth], ); if (fullLines.length === 0) { return null; } const verticalLine = ( ); return ( {isFirstThinking && ( <> {' '} Thinking...{' '} {verticalLine} )} {!isFirstThinking && ( {verticalLine} )} {fullSummaryDisplayLines.map((line, index) => ( {verticalLine} {line} ))} {fullBodyDisplayLines.map((line, index) => ( {verticalLine} {line} ))} ); };