diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts index a05ed42ffe..5521012e9c 100644 --- a/ui-tui/src/__tests__/textInputWrap.test.ts +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -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) }) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 7eb9458066..241e0978c2 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -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) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index dd8053230c..0d29143b10 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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) => ( - - {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} + + {i === 0 ? promptLabel : ' '.repeat(promptWidth)} {line || ' '} @@ -167,12 +169,12 @@ const ComposerPane = memo(function ComposerPane({ ))} - + {sh ? ( - $ + {promptLabel} ) : ( - {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} + {composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel} )} diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index bac19b0ada..b5845e11a3 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -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,