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 { providerDisplayNames } from '../domain/providers.js'
@ -8,6 +8,8 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
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))
@ -27,6 +29,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const [modelIdx, setModelIdx] = useState(0)
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(() => {
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
.then(raw => {
@ -168,16 +177,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const { items, off } = visibleItems(rows, providerIdx)
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber} wrap="truncate-end">
Select Provider
</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">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</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) => {
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}
inverse={providerIdx === idx}
key={providers[idx]?.slug ?? `row-${idx}`}
wrap="truncate-end"
>
{providerIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
) : (
<Text color={t.color.dim} key={`pad-${i}`}>
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
{' '}
</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}>/ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
<Text color={t.color.dim} wrap="truncate-end">
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>
)
}
@ -211,16 +231,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const { items, off } = visibleItems(models, modelIdx)
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber} wrap="truncate-end">
Select Model
</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">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</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) => {
const row = items[i]
@ -228,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (!row) {
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
</Text>
) : (
<Text color={t.color.dim} key={`pad-${i}`}>
<Text color={t.color.dim} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
@ -244,6 +268,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
color={modelIdx === idx ? t.color.amber : t.color.dim}
inverse={modelIdx === idx}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end"
>
{modelIdx === idx ? '▸ ' : ' '}
{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` : ' '}
</Text>
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
<Text color={t.color.dim}>
<Text color={t.color.dim} wrap="truncate-end">
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'}
</Text>
</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 type { GatewayClient } from '../gatewayClient.js'
@ -7,6 +7,8 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
const VISIBLE = 15
const MIN_WIDTH = 60
const MAX_WIDTH = 120
const age = (ts: number) => {
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 [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => {
gw.request<SessionListResponse>('session.list', { limit: 20 })
.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))
return (
<Box flexDirection="column">
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}>
Resume Session
</Text>
@ -128,7 +133,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
</Text>
</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)'}
</Text>
</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 type { GatewayClient } from '../gatewayClient.js'
@ -6,6 +6,8 @@ import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
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))
@ -26,6 +28,9 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
.then(r => {
@ -186,7 +191,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
if (err && stage === 'category') {
return (
<Box flexDirection="column">
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text>
</Box>
@ -195,7 +200,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
if (!cats.length) {
return (
<Box flexDirection="column">
<Box flexDirection="column" width={width}>
<Text color={t.color.dim}>no skills available</Text>
<Text color={t.color.dim}>Esc to cancel</Text>
</Box>
@ -207,7 +212,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const { items, off } = visibleItems(rows, catIdx)
return (
<Box flexDirection="column">
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}>
Skills Hub
</Text>
@ -224,6 +229,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
color={catIdx === idx ? t.color.amber : t.color.dim}
inverse={catIdx === idx}
key={row}
wrap="truncate-end"
>
{catIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
@ -241,7 +247,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const { items, off } = visibleItems(skills, skillIdx)
return (
<Box flexDirection="column">
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}>
{selectedCat}
</Text>
@ -259,6 +265,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
color={skillIdx === idx ? t.color.amber : t.color.dim}
inverse={skillIdx === idx}
key={row}
wrap="truncate-end"
>
{skillIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
@ -275,7 +282,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
}
return (
<Box flexDirection="column">
<Box flexDirection="column" width={width}>
<Text bold color={t.color.amber}>
{info?.name ?? skillName}
</Text>