11import React , { useState , useCallback , useEffect , useRef , useMemo } from 'react' ;
2- import { Box , Text , Static , useApp , useInput } from 'ink' ;
2+ import { Box , Text , Static , useApp , useInput , useStdout } from 'ink' ;
33import { AgentPanel } from './AgentPanel.js' ;
44import { MessageStream , renderMarkdownInline , formatDuration } from './MessageStream.js' ;
55import { InputPrompt } from './InputPrompt.js' ;
@@ -282,12 +282,16 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
282282 const width = useTerminalWidth ( ) ;
283283 const tier = useLayoutTier ( ) ;
284284 const terminalHeight = useTerminalHeight ( ) ;
285- const contentWidth = tier === 'wide' ? Math . min ( width , 120 ) : tier === 'normal' ? Math . min ( width , 80 ) : width ;
286-
287- // Budget live region height so InputPrompt is never pushed off-screen.
288- // Reserve 3 rows for InputPrompt (prompt line + hint + padding).
289- const INPUT_RESERVED_ROWS = 3 ;
290- const liveContentHeight = Math . max ( terminalHeight - INPUT_RESERVED_ROWS , 4 ) ;
285+ // Cap contentWidth at Ink's stdout columns to prevent text overflow/clipping.
286+ // In tests, Ink renders at 100 columns while process.stdout.columns may differ.
287+ const { stdout : inkStdout } = useStdout ( ) ;
288+ const renderWidth = inkStdout && 'columns' in inkStdout
289+ ? ( inkStdout as { columns ?: number } ) . columns ?? width
290+ : width ;
291+ const contentWidth = Math . min (
292+ tier === 'wide' ? Math . min ( width , 120 ) : tier === 'normal' ? Math . min ( width , 80 ) : width ,
293+ renderWidth ,
294+ ) ;
291295
292296 // Prefer lead/coordinator for first-run hint, fall back to first agent
293297 const leadAgent = welcome ?. agents . find ( a =>
@@ -299,8 +303,7 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
299303 // Determine ThinkingIndicator phase based on SDK connection state
300304 const thinkingPhase : ThinkingPhase = ! onDispatch ? 'connecting' : 'routing' ;
301305
302- // Derive @mention hint from last user message (needed because MessageStream
303- // receives messages=[] after the Static scrollback refactor).
306+ // Derive @mention hint from last user message.
304307 const mentionHint = useMemo ( ( ) => {
305308 if ( ! processing ) return undefined ;
306309 const lastUser = [ ...messages ] . reverse ( ) . find ( m => m . role === 'user' ) ;
@@ -311,13 +314,12 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
311314 return undefined ;
312315 } , [ messages , processing ] ) ;
313316
314- // Combine archived + current messages for Static rendering.
315- // This array only grows — archival moves items between the two source arrays
316- // but the combined list stays stable, which is required by Ink's Static tracking.
317- const staticMessages = useMemo (
318- ( ) => [ ...archivedMessages , ...messages ] ,
319- [ archivedMessages , messages ] ,
320- ) ;
317+ // Only archived (overflow) messages go to Static scrollback.
318+ // Current messages stay in the live region so the user can always see
319+ // the recent conversation without scrolling. This prevents the
320+ // "conversation vanishes" problem where every re-render forced the
321+ // viewport to the bottom, hiding Static scrollback content.
322+ const staticMessages = archivedMessages ;
321323 const roleMap = useMemo ( ( ) => new Map ( ( agents ?? [ ] ) . map ( a => [ a . name , a . role ] ) ) , [ agents ] ) ;
322324
323325 // Memoize the header box — rendered once into Static scroll buffer at the top.
@@ -381,13 +383,28 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
381383 const allStaticItems = useMemo ( ( ) : StaticItem [ ] => {
382384 const items : StaticItem [ ] = [ { kind : 'header' , key : 'welcome-header' } ] ;
383385 for ( let i = 0 ; i < staticMessages . length ; i ++ ) {
384- items . push ( { kind : 'msg' , key : `${ sessionId } -${ i } ` , msg : staticMessages [ i ] ! , idx : i } ) ;
386+ // Use timestamp + index-at-creation for stable keys that don't shift
387+ // when new messages are added (array only grows via append)
388+ const msg = staticMessages [ i ] ! ;
389+ const stableKey = `${ sessionId } -${ msg . timestamp . getTime ( ) } -${ i } ` ;
390+ items . push ( { kind : 'msg' , key : stableKey , msg, idx : i } ) ;
385391 }
386392 return items ;
387393 } , [ staticMessages , sessionId ] ) ;
388394
395+ // Fill the entire viewport. Ink's fullscreen clearTerminal path and
396+ // trailing-newline behavior have been patched out of ink.js, so we can
397+ // safely use the full terminal height without triggering scroll-to-top.
398+ // logUpdate tracks exactly rootHeight lines and erases/rewrites them
399+ // on each render cycle without cursor drift.
400+ const rootHeight = Math . max ( terminalHeight , 8 ) ;
401+
402+ // Derive maxVisible from terminal height so taller terminals show more
403+ // conversation context. Reserve ~8 rows for header/input/agent-panel chrome.
404+ const maxVisible = Math . max ( Math . floor ( ( terminalHeight - 8 ) / 3 ) , 3 ) ;
405+
389406 return (
390- < Box flexDirection = "column" >
407+ < Box flexDirection = "column" height = { rootHeight } >
391408 { /* Static block: header first (stays at top of scroll buffer), then messages */ }
392409 < Static items = { allStaticItems } >
393410 { ( item ) => {
@@ -443,16 +460,20 @@ export const App: React.FC<AppProps> = ({ registry, renderer, teamRoot, version,
443460 } }
444461 </ Static >
445462
446- { /* Live region: bounded height only while processing so InputPrompt stays in viewport;
447- auto-sized when idle to avoid blank space below the agent panel. */ }
448- < Box flexDirection = "column" { ...( processing ? { height : liveContentHeight , overflow : 'hidden' as const } : { } ) } >
463+ { /* Live region: always height-constrained to prevent layout shift flicker
464+ when processing state toggles. InputPrompt stays pinned at bottom.
465+ Messages are kept here (not in Static) so the user can always see the
466+ recent conversation without scrolling. maxVisible caps the message
467+ count to prevent overflow into the InputPrompt area. */ }
468+ < Box flexDirection = "column" flexGrow = { 1 } >
449469 < AgentPanel agents = { agents } streamingContent = { streamingContent } />
450- < MessageStream messages = { [ ] } agents = { agents } streamingContent = { streamingContent } processing = { processing } activityHint = { activityHint || mentionHint } agentActivities = { agentActivities } thinkingPhase = { thinkingPhase } />
470+ < MessageStream messages = { messages } agents = { agents } streamingContent = { streamingContent } processing = { processing } activityHint = { activityHint || mentionHint } agentActivities = { agentActivities } thinkingPhase = { thinkingPhase } maxVisible = { maxVisible } />
451471 </ Box >
452472 { /* Fixed input box at bottom — Copilot/Claude style */ }
453473 < Box marginTop = { 1 } borderStyle = { noColor ? undefined : 'round' } borderColor = { noColor ? undefined : 'cyan' } paddingX = { 1 } >
454474 < InputPrompt onSubmit = { handleSubmit } disabled = { processing } agentNames = { agents . map ( a => a . name ) } messageCount = { messages . length } />
455475 </ Box >
476+ { /* version is shown in the Static header — no footer duplicate needed */ }
456477 </ Box >
457478 ) ;
458479} ;
0 commit comments