fix(tui): stabilize skin prompt width

Normalize skin prompt symbols to trimmed single-line text and measure the active prompt width so wide skin glyphs do not wrap or distort the composer.
This commit is contained in:
Brooklyn Nicholson
2026-04-27 14:01:20 -05:00
parent afee8ddd27
commit e024743eca
4 changed files with 25 additions and 9 deletions

View File

@@ -44,6 +44,7 @@ describe('input metrics helpers', () => {
it('reserves gutters on wide panes without starving narrow composer width', () => {
expect(stableComposerColumns(100, 3)).toBe(93)
expect(stableComposerColumns(100, 5)).toBe(91)
expect(stableComposerColumns(10, 3)).toBe(5)
expect(stableComposerColumns(6, 3)).toBe(1)
})

View File

@@ -76,6 +76,11 @@ describe('fromSkin', () => {
expect(brand.prompt).toBe('$')
})
it('normalizes skin prompt symbols to one trimmed line', () => {
expect(fromSkin({}, { prompt_symbol: ' ⚔ \n' }).brand.prompt).toBe('⚔ ')
expect(fromSkin({}, { prompt_symbol: '\n\t' }).brand.prompt).toBe(DEFAULT_THEME.brand.prompt)
})
it('defaults for empty skin', () => {
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)

View File

@@ -1,4 +1,4 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { AlternateScreen, Box, NoSelect, ScrollBox, stringWidth, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { Fragment, memo, useMemo } from 'react'
@@ -113,8 +113,10 @@ const ComposerPane = memo(function ComposerPane({
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const pw = sh ? 2 : 3
const inputColumns = stableComposerColumns(composer.cols, pw)
const promptText = sh ? '$' : ui.theme.brand.prompt
const promptLabel = `${promptText} `
const promptWidth = Math.max(1, stringWidth(promptLabel))
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
return (
@@ -158,8 +160,8 @@ const ComposerPane = memo(function ComposerPane({
<>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.muted}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
<Box width={promptWidth}>
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
</Box>
<Text color={ui.theme.color.text}>{line || ' '}</Text>
@@ -167,12 +169,12 @@ const ComposerPane = memo(function ComposerPane({
))}
<Box position="relative">
<Box width={pw}>
<Box width={promptWidth}>
{sh ? (
<Text color={ui.theme.color.shellDollar}>$ </Text>
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
) : (
<Text bold color={ui.theme.color.prompt}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
</Text>
)}
</Box>

View File

@@ -88,6 +88,14 @@ const BRAND: ThemeBrand = {
helpHeader: '(^_^)? Commands'
}
const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
const cleaned = String(s ?? '')
.replace(/\s+/g, ' ')
.trim()
return cleaned || fallback
}
export const DARK_THEME: Theme = {
color: {
primary: '#FFD700',
@@ -254,7 +262,7 @@ export function fromSkin(
brand: {
name: branding.agent_name ?? d.brand.name,
icon: d.brand.icon,
prompt: branding.prompt_symbol ?? d.brand.prompt,
prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
welcome: branding.welcome ?? d.brand.welcome,
goodbye: branding.goodbye ?? d.brand.goodbye,
tool: toolPrefix || d.brand.tool,