From 0ce1b9fe20a53459b37b7ab27dcb88336dbea781 Mon Sep 17 00:00:00 2001 From: asheriif <30965123+asheriif@users.noreply.github.com> Date: Mon, 4 May 2026 18:58:40 +0200 Subject: [PATCH] fix(tui): preserve prompt separator width (#19340) * fix(tui): preserve prompt separator width * fix(tui): align transcript height estimates with prompt width --- ui-tui/src/__tests__/messages.test.ts | 50 +++++++++++++++++++++ ui-tui/src/__tests__/virtualHeights.test.ts | 7 +++ ui-tui/src/app/useMainApp.ts | 9 ++-- ui-tui/src/components/messageLine.tsx | 6 ++- ui-tui/src/lib/inputMetrics.ts | 10 +++++ ui-tui/src/lib/virtualHeights.ts | 10 ++++- 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index 1da4bfd4ae..1ad2b788df 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -1,7 +1,13 @@ +import { renderSync } from '@hermes/ink' +import React from 'react' +import { PassThrough } from 'stream' import { describe, expect, it } from 'vitest' +import { MessageLine } from '../components/messageLine.js' import { toTranscriptMessages } from '../domain/messages.js' import { upsert } from '../lib/messages.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' describe('toTranscriptMessages', () => { it('preserves assistant tool-call rows so resume does not drop prior turns', () => { @@ -21,6 +27,50 @@ describe('toTranscriptMessages', () => { }) }) +describe('MessageLine', () => { + it('preserves a separator after compound user prompt glyphs in transcript rows', () => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const t = { + ...DEFAULT_THEME, + brand: { ...DEFAULT_THEME.brand, prompt: 'Ψ >' } + } + + const instance = renderSync( + React.createElement(MessageLine, { + cols: 80, + msg: { role: 'user', text: 'Okay' }, + t + }), + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + const renderedLine = stripAnsi(output) + .split('\n') + .find(line => line.includes('Okay')) + + expect(renderedLine).toContain('Ψ > Okay') + }) +}) + describe('upsert', () => { it('appends when last role differs', () => { expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 4b05aa3996..f407976db3 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -17,6 +17,13 @@ describe('virtual height estimates', () => { expect(estimatedMsgHeight(msg, 35, { compact: false, details: false })).toBeGreaterThan(5) }) + it('uses compound user prompt width when estimating user message wrapping', () => { + const msg: Msg = { role: 'user', text: 'x'.repeat(21) } + + expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: '❯' })).toBe(3) + expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4) + }) + it('includes detail sections when visible', () => { const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 17924ca4a6..218654f531 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -17,6 +17,7 @@ import type { import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { appendTranscriptMessage } from '../lib/messages.js' +import { composerPromptWidth } from '../lib/inputMetrics.js' import { isMac } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' @@ -244,7 +245,8 @@ export function useMainApp(gw: GatewayClient) { }, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]) const detailsVisible = detailsLayoutKey !== 'hidden:hidden' - const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}` + const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt) + const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}` const heightCache = useMemo(() => { let cache = heightCachesRef.current.get(heightCacheKey) @@ -266,9 +268,10 @@ export function useMainApp(gw: GatewayClient) { estimatedMsgHeight(virtualRows[index]!.msg, cols, { compact: ui.compact, details: detailsVisible, - limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS + limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS, + userPrompt: ui.theme.brand.prompt }), - [cols, detailsVisible, ui.compact, virtualRows] + [cols, detailsVisible, ui.compact, ui.theme.brand.prompt, virtualRows] ) const syncHeightCache = useCallback( diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 0bf9ba6d9b..7bdfb443b7 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -5,6 +5,7 @@ import { LONG_MSG } from '../config/limits.js' import { sectionMode } from '../domain/details.js' import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' +import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js' import { boundedHistoryRenderText, boundedLiveRenderText, @@ -95,6 +96,7 @@ export const MessageLine = memo(function MessageLine({ } const { body, glyph, prefix } = ROLE[msg.role](t) + const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt) const showDetails = (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking)) @@ -163,13 +165,13 @@ export const MessageLine = memo(function MessageLine({ )} - + {glyph}{' '} - {content} + {content} ) diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 245baae96f..b5645b4331 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -1,5 +1,7 @@ import { stringWidth } from '@hermes/ink' +import type { Role } from '../types.js' + export const COMPOSER_PROMPT_GAP_WIDTH = 1 let _seg: Intl.Segmenter | null = null @@ -162,6 +164,14 @@ export function composerPromptWidth(promptText: string) { return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH } +export function transcriptGutterWidth(role: Role, userPrompt: string) { + return role === 'user' ? composerPromptWidth(userPrompt) : 3 +} + +export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) { + return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt) - 2) +} + export function stableComposerColumns(totalCols: number, promptWidth: number) { // Physical render/wrap width. Always reserve outer composer padding and // prompt prefix. Only reserve the transcript scrollbar gutter when the diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 0c673fd93a..e9439d42dd 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -1,5 +1,6 @@ import type { Msg } from '../types.js' +import { transcriptBodyWidth } from './inputMetrics.js' import { boundedHistoryRenderText } from './text.js' const hashText = (text: string) => { @@ -38,7 +39,12 @@ export const wrappedLines = (text: string, width: number) => { export const estimatedMsgHeight = ( msg: Msg, cols: number, - { compact, details, limitHistory = false }: { compact: boolean; details: boolean; limitHistory?: boolean } + { + compact, + details, + limitHistory = false, + userPrompt = '' + }: { compact: boolean; details: boolean; limitHistory?: boolean; userPrompt?: string } ) => { if (msg.kind === 'intro') { return msg.info?.version ? 9 : 5 @@ -56,7 +62,7 @@ export const estimatedMsgHeight = ( return Math.max(2, msg.todos.length + 2) } - const bodyWidth = Math.max(20, cols - 5) + const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt) const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text let h = wrappedLines(text || ' ', bodyWidth)