fix(tui): truncate long picker rows so the height stays stable

A6 added a fixed-height grid (Array.from({length: VISIBLE})), but the
row <Text> itself had no wrap prop so Ink defaulted to wrap="wrap".
A sufficiently long model or provider name would wrap to a second
visual line and bounce the overall picker height right back — which
is exactly what reappeared during the TUI v2 blitz retest on /model.

Pin every picker row (and the empty-state / padding rows) to
wrap="truncate-end" so each slot is guaranteed one line.  Applies
across modelPicker, sessionPicker, and skillsHub.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 13:49:52 -05:00
parent fc6a27098e
commit 4ada76b6ed
3 changed files with 66 additions and 27 deletions

View file

@ -1,4 +1,4 @@
import { Box, Text, useInput } from '@hermes/ink' import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { providerDisplayNames } from '../domain/providers.js' import { providerDisplayNames } from '../domain/providers.js'
@ -8,6 +8,8 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
const VISIBLE = 12 const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
@ -27,6 +29,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const [modelIdx, setModelIdx] = useState(0) const [modelIdx, setModelIdx] = useState(0)
const [stage, setStage] = useState<'model' | 'provider'>('provider') const [stage, setStage] = useState<'model' | 'provider'>('provider')
const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against.
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => { useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {}) gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
.then(raw => { .then(raw => {
@ -168,16 +177,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const { items, off } = visibleItems(rows, providerIdx) const { items, off } = visibleItems(rows, providerIdx)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.amber} wrap="truncate-end">
Select Provider Select Provider
</Text> </Text>
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text> <Text color={t.color.dim} wrap="truncate-end">
Current model: {currentModel || '(unknown)'}
</Text>
<Text color={t.color.label} wrap="truncate-end"> <Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '} {provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text> </Text>
<Text color={t.color.dim}>{off > 0 ? `${off} more` : ' '}</Text> <Text color={t.color.dim} wrap="truncate-end">
{off > 0 ? `${off} more` : ' '}
</Text>
{Array.from({ length: VISIBLE }, (_, i) => { {Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i] const row = items[i]
@ -189,21 +202,28 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
color={providerIdx === idx ? t.color.amber : t.color.dim} color={providerIdx === idx ? t.color.amber : t.color.dim}
inverse={providerIdx === idx} inverse={providerIdx === idx}
key={providers[idx]?.slug ?? `row-${idx}`} key={providers[idx]?.slug ?? `row-${idx}`}
wrap="truncate-end"
> >
{providerIdx === idx ? '▸ ' : ' '} {providerIdx === idx ? '▸ ' : ' '}
{i + 1}. {row} {i + 1}. {row}
</Text> </Text>
) : ( ) : (
<Text color={t.color.dim} key={`pad-${i}`}> <Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
{' '} {' '}
</Text> </Text>
) )
})} })}
<Text color={t.color.dim}>{off + VISIBLE < rows.length ? `${rows.length - off - VISIBLE} more` : ' '}</Text> <Text color={t.color.dim} wrap="truncate-end">
{off + VISIBLE < rows.length ? `${rows.length - off - VISIBLE} more` : ' '}
</Text>
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text> <Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.dim}>/ select · Enter choose · 1-9,0 quick · Esc cancel</Text> persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text>
<Text color={t.color.dim} wrap="truncate-end">
/ select · Enter choose · 1-9,0 quick · Esc cancel
</Text>
</Box> </Box>
) )
} }
@ -211,16 +231,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const { items, off } = visibleItems(models, modelIdx) const { items, off } = visibleItems(models, modelIdx)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.amber} wrap="truncate-end">
Select Model Select Model
</Text> </Text>
<Text color={t.color.dim}>{names[providerIdx] || '(unknown provider)'}</Text> <Text color={t.color.dim} wrap="truncate-end">
{names[providerIdx] || '(unknown provider)'}
</Text>
<Text color={t.color.label} wrap="truncate-end"> <Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '} {provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text> </Text>
<Text color={t.color.dim}>{off > 0 ? `${off} more` : ' '}</Text> <Text color={t.color.dim} wrap="truncate-end">
{off > 0 ? `${off} more` : ' '}
</Text>
{Array.from({ length: VISIBLE }, (_, i) => { {Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i] const row = items[i]
@ -228,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (!row) { if (!row) {
return !models.length && i === 0 ? ( return !models.length && i === 0 ? (
<Text color={t.color.dim} key="empty"> <Text color={t.color.dim} key="empty" wrap="truncate-end">
no models listed for this provider no models listed for this provider
</Text> </Text>
) : ( ) : (
<Text color={t.color.dim} key={`pad-${i}`}> <Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
{' '} {' '}
</Text> </Text>
) )
@ -244,6 +268,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
color={modelIdx === idx ? t.color.amber : t.color.dim} color={modelIdx === idx ? t.color.amber : t.color.dim}
inverse={modelIdx === idx} inverse={modelIdx === idx}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`} key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end"
> >
{modelIdx === idx ? '▸ ' : ' '} {modelIdx === idx ? '▸ ' : ' '}
{i + 1}. {row} {i + 1}. {row}
@ -251,12 +276,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
) )
})} })}
<Text color={t.color.dim}> <Text color={t.color.dim} wrap="truncate-end">
{off + VISIBLE < models.length ? `${models.length - off - VISIBLE} more` : ' '} {off + VISIBLE < models.length ? `${models.length - off - VISIBLE} more` : ' '}
</Text> </Text>
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text> <Text color={t.color.dim} wrap="truncate-end">
<Text color={t.color.dim}> persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text>
<Text color={t.color.dim} wrap="truncate-end">
{models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'} {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'}
</Text> </Text>
</Box> </Box>

View file

@ -1,4 +1,4 @@
import { Box, Text, useInput } from '@hermes/ink' import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js' import type { GatewayClient } from '../gatewayClient.js'
@ -7,6 +7,8 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
const VISIBLE = 15 const VISIBLE = 15
const MIN_WIDTH = 60
const MAX_WIDTH = 120
const age = (ts: number) => { const age = (ts: number) => {
const d = (Date.now() / 1000 - ts) / 86400 const d = (Date.now() / 1000 - ts) / 86400
@ -28,6 +30,9 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
const [sel, setSel] = useState(0) const [sel, setSel] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => { useEffect(() => {
gw.request<SessionListResponse>('session.list', { limit: 20 }) gw.request<SessionListResponse>('session.list', { limit: 20 })
.then(raw => { .then(raw => {
@ -99,7 +104,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
Resume Session Resume Session
</Text> </Text>
@ -128,7 +133,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
</Text> </Text>
</Box> </Box>
<Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected}> <Text bold={selected} color={selected ? t.color.amber : t.color.dim} inverse={selected} wrap="truncate-end">
{s.title || s.preview || '(untitled)'} {s.title || s.preview || '(untitled)'}
</Text> </Text>
</Box> </Box>

View file

@ -1,4 +1,4 @@
import { Box, Text, useInput } from '@hermes/ink' import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js' import type { GatewayClient } from '../gatewayClient.js'
@ -6,6 +6,8 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
const VISIBLE = 12 const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
@ -26,6 +28,9 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => { useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' }) gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
.then(r => { .then(r => {
@ -186,7 +191,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
if (err && stage === 'category') { if (err && stage === 'category') {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text> <Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <Text color={t.color.dim}>Esc to cancel</Text>
</Box> </Box>
@ -195,7 +200,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
if (!cats.length) { if (!cats.length) {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text color={t.color.dim}>no skills available</Text> <Text color={t.color.dim}>no skills available</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <Text color={t.color.dim}>Esc to cancel</Text>
</Box> </Box>
@ -207,7 +212,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const { items, off } = visibleItems(rows, catIdx) const { items, off } = visibleItems(rows, catIdx)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
Skills Hub Skills Hub
</Text> </Text>
@ -224,6 +229,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
color={catIdx === idx ? t.color.amber : t.color.dim} color={catIdx === idx ? t.color.amber : t.color.dim}
inverse={catIdx === idx} inverse={catIdx === idx}
key={row} key={row}
wrap="truncate-end"
> >
{catIdx === idx ? '▸ ' : ' '} {catIdx === idx ? '▸ ' : ' '}
{i + 1}. {row} {i + 1}. {row}
@ -241,7 +247,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const { items, off } = visibleItems(skills, skillIdx) const { items, off } = visibleItems(skills, skillIdx)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
{selectedCat} {selectedCat}
</Text> </Text>
@ -259,6 +265,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
color={skillIdx === idx ? t.color.amber : t.color.dim} color={skillIdx === idx ? t.color.amber : t.color.dim}
inverse={skillIdx === idx} inverse={skillIdx === idx}
key={row} key={row}
wrap="truncate-end"
> >
{skillIdx === idx ? '▸ ' : ' '} {skillIdx === idx ? '▸ ' : ' '}
{i + 1}. {row} {i + 1}. {row}
@ -275,7 +282,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
} }
return ( return (
<Box flexDirection="column"> <Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}> <Text bold color={t.color.amber}>
{info?.name ?? skillName} {info?.name ?? skillName}
</Text> </Text>