44 * SPDX-License-Identifier: Apache-2.0
55 */
66
7- import { useCallback , useEffect , useMemo , useState } from 'react' ;
8- import { Box , Static , Text , useStdout } from 'ink' ;
7+ import { useCallback , useEffect , useMemo , useState , useRef } from 'react' ;
8+ import { Box , DOMElement , measureElement , Static , Text , useStdout } from 'ink' ;
99import { StreamingState , type HistoryItem } from './types.js' ;
1010import { useGeminiStream } from './hooks/useGeminiStream.js' ;
1111import { useLoadingIndicator } from './hooks/useLoadingIndicator.js' ;
@@ -46,6 +46,7 @@ export const App = ({
4646 startupWarnings = [ ] ,
4747} : AppProps ) => {
4848 const { history, addItem, clearItems } = useHistory ( ) ;
49+ const [ staticNeedsRefresh , setStaticNeedsRefresh ] = useState ( false ) ;
4950 const [ staticKey , setStaticKey ] = useState ( 0 ) ;
5051 const refreshStatic = useCallback ( ( ) => {
5152 setStaticKey ( ( prev ) => prev + 1 ) ;
@@ -55,7 +56,8 @@ export const App = ({
5556 const [ debugMessage , setDebugMessage ] = useState < string > ( '' ) ;
5657 const [ showHelp , setShowHelp ] = useState < boolean > ( false ) ;
5758 const [ themeError , setThemeError ] = useState < string | null > ( null ) ;
58-
59+ const [ availableTerminalHeight , setAvailableTerminalHeight ] =
60+ useState < number > ( 0 ) ;
5961 const {
6062 isThemeDialogOpen,
6163 openThemeDialog,
@@ -193,12 +195,51 @@ export const App = ({
193195
194196 // --- Render Logic ---
195197
196- // Get terminal width
198+ // Get terminal dimensions
199+
197200 const { stdout } = useStdout ( ) ;
198201 const terminalWidth = stdout ?. columns ?? 80 ;
202+ const terminalHeight = stdout ?. rows ?? 24 ;
203+ const footerRef = useRef < DOMElement > ( null ) ;
204+ const pendingHistoryItemRef = useRef < DOMElement > ( null ) ;
205+
199206 // Calculate width for suggestions, leave some padding
200207 const suggestionsWidth = Math . max ( 60 , Math . floor ( terminalWidth * 0.8 ) ) ;
201208
209+ useEffect ( ( ) => {
210+ const staticExtraHeight = /* margins and padding */ 3 ;
211+ const fullFooterMeasurement = measureElement ( footerRef . current ! ) ;
212+ const fullFooterHeight = fullFooterMeasurement . height ;
213+
214+ setAvailableTerminalHeight (
215+ terminalHeight - fullFooterHeight - staticExtraHeight ,
216+ ) ;
217+ } , [ terminalHeight ] ) ;
218+
219+ useEffect ( ( ) => {
220+ if ( ! pendingHistoryItem ) {
221+ return ;
222+ }
223+
224+ const pendingItemDimensions = measureElement (
225+ pendingHistoryItemRef . current ! ,
226+ ) ;
227+
228+ // If our pending history item happens to exceed the terminal height we will most likely need to refresh
229+ // our static collection to ensure no duplication or tearing. This is currently working around a core bug
230+ // in Ink which we have a PR out to fix: https://github.com/vadimdemedes/ink/pull/717
231+ if ( pendingItemDimensions . height > availableTerminalHeight ) {
232+ setStaticNeedsRefresh ( true ) ;
233+ }
234+ } , [ pendingHistoryItem , availableTerminalHeight , streamingState ] ) ;
235+
236+ useEffect ( ( ) => {
237+ if ( streamingState === StreamingState . Idle && staticNeedsRefresh ) {
238+ setStaticNeedsRefresh ( false ) ;
239+ refreshStatic ( ) ;
240+ }
241+ } , [ streamingState , refreshStatic , staticNeedsRefresh ] ) ;
242+
202243 return (
203244 < Box flexDirection = "column" marginBottom = { 1 } width = "90%" >
204245 { /*
@@ -219,146 +260,151 @@ export const App = ({
219260 < Header />
220261 < Tips />
221262 </ Box > ,
222- ...history . map ( ( h ) => < HistoryItemDisplay key = { h . id } item = { h } /> ) ,
263+ ...history . map ( ( h ) => < HistoryItemDisplay availableTerminalHeight = { availableTerminalHeight } key = { h . id } item = { h } /> ) ,
223264 ] }
224265 >
225266 { ( item ) => item }
226267 </ Static >
227268 { pendingHistoryItem && (
228- < HistoryItemDisplay
229- // TODO(taehykim): It seems like references to ids aren't necessary in
230- // HistoryItemDisplay. Refactor later. Use a fake id for now.
231- item = { { ...pendingHistoryItem , id : 0 } }
232- />
269+ < Box ref = { pendingHistoryItemRef } >
270+ < HistoryItemDisplay
271+ availableTerminalHeight = { availableTerminalHeight }
272+ // TODO(taehykim): It seems like references to ids aren't necessary in
273+ // HistoryItemDisplay. Refactor later. Use a fake id for now.
274+ item = { { ...pendingHistoryItem , id : 0 } }
275+ />
276+ </ Box >
233277 ) }
234278 { showHelp && < Help commands = { slashCommands } /> }
235279
236- { startupWarnings . length > 0 && (
237- < Box
238- borderStyle = "round"
239- borderColor = { Colors . AccentYellow }
240- paddingX = { 1 }
241- marginY = { 1 }
242- flexDirection = "column"
243- >
244- { startupWarnings . map ( ( warning , index ) => (
245- < Text key = { index } color = { Colors . AccentYellow } >
246- { warning }
247- </ Text >
248- ) ) }
249- </ Box >
250- ) }
280+ < Box flexDirection = "column" ref = { footerRef } >
281+ { startupWarnings . length > 0 && (
282+ < Box
283+ borderStyle = "round"
284+ borderColor = { Colors . AccentYellow }
285+ paddingX = { 1 }
286+ marginY = { 1 }
287+ flexDirection = "column"
288+ >
289+ { startupWarnings . map ( ( warning , index ) => (
290+ < Text key = { index } color = { Colors . AccentYellow } >
291+ { warning }
292+ </ Text >
293+ ) ) }
294+ </ Box >
295+ ) }
251296
252- { isThemeDialogOpen ? (
253- < Box flexDirection = "column" >
254- { themeError && (
255- < Box marginBottom = { 1 } >
256- < Text color = { Colors . AccentRed } > { themeError } </ Text >
257- </ Box >
258- ) }
259- < ThemeDialog
260- onSelect = { handleThemeSelect }
261- onHighlight = { handleThemeHighlight }
262- settings = { settings }
263- setQuery = { setQuery }
264- />
265- </ Box >
266- ) : (
267- < >
268- < LoadingIndicator
269- isLoading = { streamingState === StreamingState . Responding }
270- currentLoadingPhrase = { currentLoadingPhrase }
271- elapsedTime = { elapsedTime }
272- />
273- { isInputActive && (
274- < >
275- < Box
276- marginTop = { 1 }
277- display = "flex"
278- justifyContent = "space-between"
279- width = "100%"
280- >
281- < Box >
282- < Text color = { Colors . SubtleComment } > cwd: </ Text >
283- < Text color = { Colors . LightBlue } >
284- { shortenPath ( config . getTargetDir ( ) , 70 ) }
285- </ Text >
286- </ Box >
297+ { isThemeDialogOpen ? (
298+ < Box flexDirection = "column" >
299+ { themeError && (
300+ < Box marginBottom = { 1 } >
301+ < Text color = { Colors . AccentRed } > { themeError } </ Text >
287302 </ Box >
288-
289- < InputPrompt
290- query = { query }
291- onChange = { setQuery }
292- onChangeAndMoveCursor = { onChangeAndMoveCursor }
293- editorState = { editorState }
294- onSubmit = { handleFinalSubmit } // Pass handleFinalSubmit directly
295- showSuggestions = { completion . showSuggestions }
296- suggestions = { completion . suggestions }
297- activeSuggestionIndex = { completion . activeSuggestionIndex }
298- userMessages = { userMessages } // Pass userMessages
299- navigateSuggestionUp = { completion . navigateUp }
300- navigateSuggestionDown = { completion . navigateDown }
301- resetCompletion = { completion . resetCompletionState }
302- setEditorState = { setEditorState }
303- onClearScreen = { handleClearScreen } // Added onClearScreen prop
304- />
305- { completion . showSuggestions && (
306- < Box >
307- < SuggestionsDisplay
308- suggestions = { completion . suggestions }
309- activeIndex = { completion . activeSuggestionIndex }
310- isLoading = { completion . isLoadingSuggestions }
311- width = { suggestionsWidth }
312- scrollOffset = { completion . visibleStartIndex }
313- userInput = { query }
314- />
303+ ) }
304+ < ThemeDialog
305+ onSelect = { handleThemeSelect }
306+ onHighlight = { handleThemeHighlight }
307+ settings = { settings }
308+ setQuery = { setQuery }
309+ />
310+ </ Box >
311+ ) : (
312+ < >
313+ < LoadingIndicator
314+ isLoading = { streamingState === StreamingState . Responding }
315+ currentLoadingPhrase = { currentLoadingPhrase }
316+ elapsedTime = { elapsedTime }
317+ />
318+ { isInputActive && (
319+ < >
320+ < Box
321+ marginTop = { 1 }
322+ display = "flex"
323+ justifyContent = "space-between"
324+ width = "100%"
325+ >
326+ < Box >
327+ < Text color = { Colors . SubtleComment } > cwd: </ Text >
328+ < Text color = { Colors . LightBlue } >
329+ { shortenPath ( config . getTargetDir ( ) , 70 ) }
330+ </ Text >
331+ </ Box >
315332 </ Box >
316- ) }
317- </ >
318- ) }
319- </ >
320- ) }
321333
322- { initError && streamingState !== StreamingState . Responding && (
323- < Box
324- borderStyle = "round"
325- borderColor = { Colors . AccentRed }
326- paddingX = { 1 }
327- marginBottom = { 1 }
328- >
329- { history . find (
330- ( item ) => item . type === 'error' && item . text ?. includes ( initError ) ,
331- ) ?. text ? (
332- < Text color = { Colors . AccentRed } >
333- {
334- history . find (
335- ( item ) =>
336- item . type === 'error' && item . text ?. includes ( initError ) ,
337- ) ?. text
338- }
339- </ Text >
340- ) : (
341- < >
342- < Text color = { Colors . AccentRed } >
343- Initialization Error: { initError }
344- </ Text >
334+ < InputPrompt
335+ query = { query }
336+ onChange = { setQuery }
337+ onChangeAndMoveCursor = { onChangeAndMoveCursor }
338+ editorState = { editorState }
339+ onSubmit = { handleFinalSubmit } // Pass handleFinalSubmit directly
340+ showSuggestions = { completion . showSuggestions }
341+ suggestions = { completion . suggestions }
342+ activeSuggestionIndex = { completion . activeSuggestionIndex }
343+ userMessages = { userMessages } // Pass userMessages
344+ navigateSuggestionUp = { completion . navigateUp }
345+ navigateSuggestionDown = { completion . navigateDown }
346+ resetCompletion = { completion . resetCompletionState }
347+ setEditorState = { setEditorState }
348+ onClearScreen = { handleClearScreen } // Added onClearScreen prop
349+ />
350+ { completion . showSuggestions && (
351+ < Box >
352+ < SuggestionsDisplay
353+ suggestions = { completion . suggestions }
354+ activeIndex = { completion . activeSuggestionIndex }
355+ isLoading = { completion . isLoadingSuggestions }
356+ width = { suggestionsWidth }
357+ scrollOffset = { completion . visibleStartIndex }
358+ userInput = { query }
359+ />
360+ </ Box >
361+ ) }
362+ </ >
363+ ) }
364+ </ >
365+ ) }
366+
367+ { initError && streamingState !== StreamingState . Responding && (
368+ < Box
369+ borderStyle = "round"
370+ borderColor = { Colors . AccentRed }
371+ paddingX = { 1 }
372+ marginBottom = { 1 }
373+ >
374+ { history . find (
375+ ( item ) => item . type === 'error' && item . text ?. includes ( initError ) ,
376+ ) ?. text ? (
345377 < Text color = { Colors . AccentRed } >
346- { ' ' }
347- Please check API key and configuration.
378+ {
379+ history . find (
380+ ( item ) =>
381+ item . type === 'error' && item . text ?. includes ( initError ) ,
382+ ) ?. text
383+ }
348384 </ Text >
349- </ >
350- ) }
351- </ Box >
352- ) }
385+ ) : (
386+ < >
387+ < Text color = { Colors . AccentRed } >
388+ Initialization Error: { initError }
389+ </ Text >
390+ < Text color = { Colors . AccentRed } >
391+ { ' ' }
392+ Please check API key and configuration.
393+ </ Text >
394+ </ >
395+ ) }
396+ </ Box >
397+ ) }
353398
354- < Footer
355- config = { config }
356- debugMode = { config . getDebugMode ( ) }
357- debugMessage = { debugMessage }
358- cliVersion = { cliVersion }
359- geminiMdFileCount = { geminiMdFileCount }
360- />
361- < ConsoleOutput />
399+ < Footer
400+ config = { config }
401+ debugMode = { config . getDebugMode ( ) }
402+ debugMessage = { debugMessage }
403+ cliVersion = { cliVersion }
404+ geminiMdFileCount = { geminiMdFileCount }
405+ />
406+ < ConsoleOutput />
407+ </ Box >
362408 </ Box >
363409 ) ;
364410} ;
0 commit comments