TUI compound prompt separator spacing fixes
Cluster 18996 · other. Data as of
Nothing open on GitHub. Fix merged in #19340 on 4 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #19340. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #19340. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address a bug where compound prompt symbols lose their separator space or gutter width in the TUI, with fixes to preserve and correctly scale that spacing.
Open issues0
Open PRs0
Fixes on record
-
#19340 fix(tui): preserve prompt separator width
files:
ui-tui/src/__tests__/messages.test.ts,ui-tui/src/__tests__/virtualHeights.test.ts,ui-tui/src/app/useMainApp.ts,ui-tui/src/components/messageLine.tsx,ui-tui/src/lib/inputMetrics.ts,ui-tui/src/lib/virtualHeights.tsShow the diff
-
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) -
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'] } -
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( -
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({ )} <Box> - <NoSelect flexShrink={0} fromLeftEdge width={3}> + <NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}> <Text bold={msg.role === 'user'} color={prefix}> {glyph}{' '} </Text> </NoSelect> - <Box width={Math.max(20, cols - 5)}>{content}</Box> + <Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt)}>{content}</Box> </Box> </Box> ) -
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 -
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)
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#19010 fix(tui): scale transcript prompt gutter for compound prompt symbols · done elsewhere →
0ce1b9f,#19340
Closed issues · 1
- Closed#18996Bug: TUI compound prompt symbols lose separator space in sent messages
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- other
- Merged PRs
- #19340
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 4 May 2026