NousResearch/hermes-agent

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

0open issues
0open PRs
3items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #19340 fix(tui): preserve prompt separator width

    merged to main

    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.ts

    Show 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.00 means 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 Lab

At a glance

Subsystem
other
Merged PRs
#19340
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
4 May 2026

For your agent

The same facts, typed.