Skip to content

Commit a0d9c90

Browse files
committed
Make the model selection screen work well on smaller-height terminals
1 parent 718d4fc commit a0d9c90

4 files changed

Lines changed: 334 additions & 57 deletions

File tree

cli/src/components/freebuff-model-selector.tsx

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { TextAttributes } from '@opentui/core'
22
import { 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

511
import { Button } from './button'
612
import {
@@ -24,7 +30,7 @@ import {
2430
} from '../utils/freebuff-model-navigation'
2531

2632
import 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
}

cli/src/components/limited-landing-panel.tsx

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,33 @@
11
import { TextAttributes } from '@opentui/core'
22
import { useKeyboard } from '@opentui/react'
3-
import React, { useCallback, useState } from 'react'
3+
import React, { useCallback, useRef, useState } from 'react'
44

55
import { Button } from './button'
66
import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
7+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
78
import { useTheme } from '../hooks/use-theme'
89
import {
910
getFreebuffModel,
1011
LIMITED_FREEBUFF_MODEL_ID,
1112
} from '@codebuff/common/constants/freebuff-models'
1213

13-
import type { KeyEvent } from '@opentui/core'
14+
import type { KeyEvent, ScrollBoxRenderable } from '@opentui/core'
1415

1516
interface LimitedLandingPanelProps {
1617
/** Pre-composed session-counter line (e.g. "0 of 5 sessions used · resets
1718
* in 8h 21m"). Parent owns the colors so the "used" count can flip to
1819
* the warning color when exhausted without this component re-deriving the
1920
* quota math. */
2021
sessionCounter: React.ReactNode
22+
/** Plain-text form of the same counter, used only to measure how many rows
23+
* it wraps to so the scroll budget is exact. */
24+
sessionCounterText: string
2125
/** True when the shared per-day quota is fully spent. Disables the CTA. */
2226
isQuotaExhausted: boolean
27+
/** Max vertical rows the panel may occupy. When its content is taller the
28+
* panel scrolls (scrollbar shown) instead of letting flexbox compress the
29+
* bordered button onto its own border. */
30+
maxHeight: number
2331
}
2432

2533
/**
@@ -32,11 +40,48 @@ interface LimitedLandingPanelProps {
3240
*/
3341
export const LimitedLandingPanel: React.FC<LimitedLandingPanelProps> = ({
3442
sessionCounter,
43+
sessionCounterText,
3544
isQuotaExhausted,
45+
maxHeight,
3646
}) => {
3747
const theme = useTheme()
48+
const { contentMaxWidth } = useTerminalDimensions()
3849
const model = getFreebuffModel(LIMITED_FREEBUFF_MODEL_ID)
3950
const [pending, setPending] = useState(false)
51+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
52+
53+
// Rendered height of the panel, matching the JSX below row-for-row so the
54+
// scroll budget is exact: name + warning (each wrap-aware) + the counter
55+
// line with its 1-row top/bottom margins + the 3-row bordered button.
56+
const wrappedRows = (text: string) =>
57+
Math.max(1, Math.ceil(text.length / contentMaxWidth))
58+
const contentHeight =
59+
wrappedRows(model.displayName) +
60+
(model.warning ? wrappedRows(model.warning) : 0) +
61+
1 /* counter marginTop */ +
62+
wrappedRows(sessionCounterText) +
63+
1 /* counter marginBottom */ +
64+
3 /* button: 2 border rows + label */
65+
const needsScroll = contentHeight > maxHeight
66+
const viewportHeight = Math.max(1, Math.min(contentHeight, maxHeight))
67+
68+
// A scrollbox stretches to fill its parent, which would left-align the
69+
// panel; the old plain box sized to its content and the parent centered
70+
// it. Restore that by pinning the scrollbox to its content width (widest
71+
// of name / warning / counter / the bordered button) so `alignItems:
72+
// 'center'` on the parent can center the whole block again.
73+
const BUTTON_LABEL = 'Start session Enter'
74+
const BUTTON_CHROME = 6 // 2 border + 4 padding (paddingLeft/Right 2)
75+
const panelWidth =
76+
Math.min(
77+
contentMaxWidth,
78+
Math.max(
79+
model.displayName.length,
80+
model.warning?.length ?? 0,
81+
sessionCounterText.length,
82+
BUTTON_LABEL.length + BUTTON_CHROME,
83+
),
84+
) + (needsScroll ? 1 : 0) /* scrollbar gutter */
4085

4186
const interactable = !pending && !isQuotaExhausted
4287

@@ -64,24 +109,54 @@ export const LimitedLandingPanel: React.FC<LimitedLandingPanelProps> = ({
64109
)
65110

66111
return (
67-
<box
112+
<scrollbox
113+
ref={scrollRef}
114+
scrollX={false}
115+
scrollbarOptions={{ visible: false }}
116+
verticalScrollbarOptions={{
117+
visible: needsScroll,
118+
trackOptions: { width: 1 },
119+
}}
68120
style={{
69-
flexDirection: 'column',
70-
alignItems: 'flex-start',
71-
gap: 0,
121+
height: viewportHeight,
122+
width: panelWidth,
123+
alignSelf: 'center',
124+
flexShrink: 0,
125+
rootOptions: {
126+
flexDirection: 'row',
127+
backgroundColor: 'transparent',
128+
},
129+
wrapperOptions: {
130+
border: false,
131+
backgroundColor: 'transparent',
132+
flexDirection: 'column',
133+
},
134+
contentOptions: {
135+
flexDirection: 'column',
136+
alignItems: 'flex-start',
137+
gap: 0,
138+
backgroundColor: 'transparent',
139+
},
72140
}}
73141
>
74-
<text style={{ wrapMode: 'word' }}>
142+
<text style={{ wrapMode: 'word', flexShrink: 0 }}>
75143
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
76144
{model.displayName}
77145
</span>
78146
</text>
79147
{model.warning && (
80-
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
148+
<text style={{ fg: theme.muted, wrapMode: 'word', flexShrink: 0 }}>
81149
{model.warning}
82150
</text>
83151
)}
84-
<text style={{ marginTop: 1, marginBottom: 1, wrapMode: 'word' }}>
152+
<text
153+
style={{
154+
marginTop: 1,
155+
marginBottom: 1,
156+
wrapMode: 'word',
157+
flexShrink: 0,
158+
}}
159+
>
85160
{sessionCounter}
86161
</text>
87162
<Button
@@ -91,6 +166,7 @@ export const LimitedLandingPanel: React.FC<LimitedLandingPanelProps> = ({
91166
borderColor: interactable ? theme.primary : theme.border,
92167
paddingLeft: 2,
93168
paddingRight: 2,
169+
flexShrink: 0,
94170
}}
95171
border={['top', 'bottom', 'left', 'right']}
96172
>
@@ -107,6 +183,6 @@ export const LimitedLandingPanel: React.FC<LimitedLandingPanelProps> = ({
107183
)}
108184
</text>
109185
</Button>
110-
</box>
186+
</scrollbox>
111187
)
112188
}

0 commit comments

Comments
 (0)