11import { TextAttributes } from '@opentui/core'
22import { useKeyboard } from '@opentui/react'
3- import React , { useCallback , useEffect , useMemo , useState } from 'react'
3+ import React , {
4+ useCallback ,
5+ useEffect ,
6+ useMemo ,
7+ useRef ,
8+ useState ,
9+ } from 'react'
410
511import { Button } from './button'
612import {
@@ -24,7 +30,7 @@ import {
2430} from '../utils/freebuff-model-navigation'
2531
2632import type { FreebuffModelOption } from '@codebuff/common/constants/freebuff-models'
27- import type { KeyEvent } from '@opentui/core'
33+ import type { KeyEvent , ScrollBoxRenderable } from '@opentui/core'
2834
2935// Section grouping: premium models share one quota pool, unlimited has none.
3036// Putting the tier on a section header lets each row drop its redundant
@@ -58,8 +64,22 @@ type Section = {
5864 * PREMIUM section header. Names align in a column so taglines line up across
5965 * rows. On narrow terminals the secondary details (warning / deployment
6066 * hours) drop onto an indented second line under the row.
67+ *
68+ * On short terminals the parent passes `maxHeight`: the row list then lives
69+ * in a scrollbox capped at that many rows, a scrollbar appears when the
70+ * models don't all fit, and Tab/arrow navigation keeps the focused row
71+ * scrolled into view.
6172 */
62- export const FreebuffModelSelector : React . FC = ( ) => {
73+ interface FreebuffModelSelectorProps {
74+ /** Max vertical rows the picker may occupy. When the rendered rows exceed
75+ * this, the list scrolls (scrollbar shown, focused row kept in view);
76+ * otherwise the scrollbox shrinks to fit and no scrollbar appears. */
77+ maxHeight : number
78+ }
79+
80+ export const FreebuffModelSelector : React . FC < FreebuffModelSelectorProps > = ( {
81+ maxHeight,
82+ } ) => {
6383 const theme = useTheme ( )
6484 // contentMaxWidth (not terminalWidth) is the real budget — the parent
6585 // waiting-room screen wraps this picker in a `maxWidth: contentMaxWidth`
@@ -217,6 +237,50 @@ export const FreebuffModelSelector: React.FC = () => {
217237 }
218238 } , [ availableModels , contentMaxWidth , deploymentAvailabilityLabel , showTagline ] )
219239
240+ // Flattened vertical layout: every model's top offset + height within the
241+ // scroll content, plus the total. Mirrors the JSX below exactly so the
242+ // auto-scroll math lands the focused row precisely. A button is 2 border
243+ // rows + its text line(s); in wrapDetails mode a row with a warning or
244+ // deployment-hours label spills its details onto a second indented line.
245+ // Headers add 1 row; sections after the first add 1 row of marginTop.
246+ const SECTION_GAP = 1
247+ const { totalHeight, offsetById } = useMemo ( ( ) => {
248+ const offsets : Record < string , { top : number ; height : number } > = { }
249+ let y = 0
250+ sections . forEach ( ( section , idx ) => {
251+ if ( idx > 0 ) y += SECTION_GAP
252+ if ( section . label ) y += 1
253+ section . models . forEach ( ( m ) => {
254+ const wraps =
255+ wrapDetails && ( ! ! m . warning || m . availability === 'deployment_hours' )
256+ const h = 2 /* borders */ + ( wraps ? 2 : 1 )
257+ offsets [ m . id ] = { top : y , height : h }
258+ y += h
259+ } )
260+ } )
261+ return { totalHeight : y , offsetById : offsets }
262+ } , [ sections , wrapDetails ] )
263+
264+ const needsScroll = totalHeight > maxHeight
265+ const scrollViewportHeight = Math . max ( 1 , Math . min ( totalHeight , maxHeight ) )
266+ const scrollRef = useRef < ScrollBoxRenderable | null > ( null )
267+
268+ // Keep the keyboard-focused row inside the viewport as the user Tabs/arrows
269+ // through a list taller than the available rows.
270+ useEffect ( ( ) => {
271+ const sb = scrollRef . current
272+ if ( ! sb || ! needsScroll ) return
273+ const entry = offsetById [ focusedId ]
274+ if ( ! entry ) return
275+ const viewportHeight = sb . viewport . height
276+ const currentScroll = sb . scrollTop
277+ if ( entry . top < currentScroll ) {
278+ sb . scrollTop = entry . top
279+ } else if ( entry . top + entry . height > currentScroll + viewportHeight ) {
280+ sb . scrollTop = entry . top + entry . height - viewportHeight
281+ }
282+ } , [ focusedId , offsetById , needsScroll ] )
283+
220284 const isJoinable = useCallback (
221285 ( modelId : string ) => {
222286 if ( ! isFreebuffModelAvailable ( modelId , new Date ( now ) ) ) return false
@@ -376,30 +440,61 @@ export const FreebuffModelSelector: React.FC = () => {
376440 )
377441 }
378442
379- return (
443+ const sectionsContent = sections . map ( ( section , sectionIdx ) => (
380444 < box
445+ key = { section . key }
381446 style = { {
382447 flexDirection : 'column' ,
383448 alignItems : 'flex-start' ,
384449 gap : 0 ,
450+ marginTop : sectionIdx === 0 ? 0 : SECTION_GAP ,
385451 } }
386452 >
387- { sections . map ( ( section , sectionIdx ) => (
388- < box
389- key = { section . key }
390- style = { {
391- flexDirection : 'column' ,
392- alignItems : 'flex-start' ,
393- gap : 0 ,
394- marginTop : sectionIdx === 0 ? 0 : 1 ,
395- } }
396- >
397- { section . label && (
398- < text style = { { fg : theme . muted } } > { section . label } </ text >
399- ) }
400- { section . models . map ( renderModelButton ) }
401- </ box >
402- ) ) }
453+ { section . label && (
454+ < text style = { { fg : theme . muted } } > { section . label } </ text >
455+ ) }
456+ { section . models . map ( renderModelButton ) }
403457 </ box >
458+ ) )
459+
460+ // Scrollbox clamped to the rows the parent can spare. When everything fits
461+ // it shrinks to the content height and no scrollbar shows, so tall
462+ // terminals look exactly like a plain column.
463+ return (
464+ < scrollbox
465+ ref = { scrollRef }
466+ scrollX = { false }
467+ scrollbarOptions = { { visible : false } }
468+ verticalScrollbarOptions = { {
469+ visible : needsScroll ,
470+ trackOptions : { width : 1 } ,
471+ } }
472+ style = { {
473+ height : scrollViewportHeight ,
474+ // A scrollbox stretches to fill its parent, which would left-align
475+ // the picker; pin it to the button column width (plus a gutter for
476+ // the scrollbar) so the landing block stays content-sized and the
477+ // parent can center it as it did before this was a scrollbox.
478+ width : buttonOuterWidth + ( needsScroll ? 1 : 0 ) ,
479+ flexShrink : 0 ,
480+ rootOptions : {
481+ flexDirection : 'row' ,
482+ backgroundColor : 'transparent' ,
483+ } ,
484+ wrapperOptions : {
485+ border : false ,
486+ backgroundColor : 'transparent' ,
487+ flexDirection : 'column' ,
488+ } ,
489+ contentOptions : {
490+ flexDirection : 'column' ,
491+ alignItems : 'flex-start' ,
492+ gap : 0 ,
493+ backgroundColor : 'transparent' ,
494+ } ,
495+ } }
496+ >
497+ { sectionsContent }
498+ </ scrollbox >
404499 )
405500}
0 commit comments