mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
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:
parent
fc6a27098e
commit
4ada76b6ed
3 changed files with 66 additions and 27 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue