{"repo":"NousResearch/hermes-agent","public_id":"18996","display_label":"TUI compound prompt separator spacing fixes","summary":"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.","subsystem":"other","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19340,"merged_at":"2026-05-04T16:58:41+00:00"},"resolution":{"state":"resolved","by":"pr-19340"},"freshness":{"state":"verified","last_verified_at":"2026-05-04T16:58:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":18996,"pr_number":19340,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-04T16:58:41+00:00"}],"members":[{"kind":"issue","number":18996,"title":"Bug: TUI compound prompt symbols lose separator space in sent messages","url":null,"state":"closed"},{"kind":"pr","number":19010,"title":"fix(tui): scale transcript prompt gutter for compound prompt symbols","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0ce1b9fe20a53459b37b7ab27dcb88336dbea781","number":0},{"kind":"pr","value":"#19340","number":19340}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19340,"title":"fix(tui): preserve prompt separator width","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"ui-tui/src/__tests__/messages.test.ts","hunks":["@@ -1,7 +1,13 @@\n+import { renderSync } from '@hermes/ink'\n+import React from 'react'\n+import { PassThrough } from 'stream'\n import { describe, expect, it } from 'vitest'\n \n+import { MessageLine } from '../components/messageLine.js'\n import { toTranscriptMessages } from '../domain/messages.js'\n import { upsert } from '../lib/messages.js'\n+import { stripAnsi } from '../lib/text.js'\n+import { DEFAULT_THEME } from '../theme.js'\n \n describe('toTranscriptMessages', () => {\n   it('preserves assistant tool-call rows so resume does not drop prior turns', () => {","@@ -21,6 +27,50 @@ describe('toTranscriptMessages', () => {\n   })\n })\n \n+describe('MessageLine', () => {\n+  it('preserves a separator after compound user prompt glyphs in transcript rows', () => {\n+    const stdout = new PassThrough()\n+    const stdin = new PassThrough()\n+    const stderr = new PassThrough()\n+    let output = ''\n+\n+    Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })\n+    Object.assign(stdin, { isTTY: false })\n+    Object.assign(stderr, { isTTY: false })\n+    stdout.on('data', chunk => {\n+      output += chunk.toString()\n+    })\n+\n+    const t = {\n+      ...DEFAULT_THEME,\n+      brand: { ...DEFAULT_THEME.brand, prompt: 'Ψ >' }\n+    }\n+\n+    const instance = renderSync(\n+      React.createElement(MessageLine, {\n+        cols: 80,\n+        msg: { role: 'user', text: 'Okay' },\n+        t\n+      }),\n+      {\n+        patchConsole: false,\n+        stderr: stderr as NodeJS.WriteStream,\n+        stdin: stdin as NodeJS.ReadStream,\n+        stdout: stdout as NodeJS.WriteStream\n+      }\n+    )\n+\n+    instance.unmount()\n+    instance.cleanup()\n+\n+    const renderedLine = stripAnsi(output)\n+      .split('\\n')\n+      .find(line => line.includes('Okay'))\n+\n+    expect(renderedLine).toContain('Ψ > Okay')\n+  })\n+})\n+\n describe('upsert', () => {\n   it('appends when last role differs', () => {\n     expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)"]},{"file":"ui-tui/src/__tests__/virtualHeights.test.ts","hunks":["@@ -17,6 +17,13 @@ describe('virtual height estimates', () => {\n     expect(estimatedMsgHeight(msg, 35, { compact: false, details: false })).toBeGreaterThan(5)\n   })\n \n+  it('uses compound user prompt width when estimating user message wrapping', () => {\n+    const msg: Msg = { role: 'user', text: 'x'.repeat(21) }\n+\n+    expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: '❯' })).toBe(3)\n+    expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4)\n+  })\n+\n   it('includes detail sections when visible', () => {\n     const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\\nline 2', tools: ['Tool A', 'Tool B'] }\n "]},{"file":"ui-tui/src/app/useMainApp.ts","hunks":["@@ -17,6 +17,7 @@ import type {\n import { useGitBranch } from '../hooks/useGitBranch.js'\n import { useVirtualHistory } from '../hooks/useVirtualHistory.js'\n import { appendTranscriptMessage } from '../lib/messages.js'\n+import { composerPromptWidth } from '../lib/inputMetrics.js'\n import { isMac } from '../lib/platform.js'\n import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'\n import { terminalParityHints } from '../lib/terminalParity.js'","@@ -244,7 +245,8 @@ export function useMainApp(gw: GatewayClient) {\n   }, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])\n \n   const detailsVisible = detailsLayoutKey !== 'hidden:hidden'\n-  const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`\n+  const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt)\n+  const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`\n \n   const heightCache = useMemo(() => {\n     let cache = heightCachesRef.current.get(heightCacheKey)","@@ -266,9 +268,10 @@ export function useMainApp(gw: GatewayClient) {\n       estimatedMsgHeight(virtualRows[index]!.msg, cols, {\n         compact: ui.compact,\n         details: detailsVisible,\n-        limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS\n+        limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS,\n+        userPrompt: ui.theme.brand.prompt\n       }),\n-    [cols, detailsVisible, ui.compact, virtualRows]\n+    [cols, detailsVisible, ui.compact, ui.theme.brand.prompt, virtualRows]\n   )\n \n   const syncHeightCache = useCallback("]},{"file":"ui-tui/src/components/messageLine.tsx","hunks":["@@ -5,6 +5,7 @@ import { LONG_MSG } from '../config/limits.js'\n import { sectionMode } from '../domain/details.js'\n import { userDisplay } from '../domain/messages.js'\n import { ROLE } from '../domain/roles.js'\n+import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'\n import {\n   boundedHistoryRenderText,\n   boundedLiveRenderText,","@@ -95,6 +96,7 @@ export const MessageLine = memo(function MessageLine({\n   }\n \n   const { body, glyph, prefix } = ROLE[msg.role](t)\n+  const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)\n \n   const showDetails =\n     (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))","@@ -163,13 +165,13 @@ export const MessageLine = memo(function MessageLine({\n       )}\n \n       <Box>\n-        <NoSelect flexShrink={0} fromLeftEdge width={3}>\n+        <NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>\n           <Text bold={msg.role === 'user'} color={prefix}>\n             {glyph}{' '}\n           </Text>\n         </NoSelect>\n \n-        <Box width={Math.max(20, cols - 5)}>{content}</Box>\n+        <Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt)}>{content}</Box>\n       </Box>\n     </Box>\n   )"]},{"file":"ui-tui/src/lib/inputMetrics.ts","hunks":["@@ -1,5 +1,7 @@\n import { stringWidth } from '@hermes/ink'\n \n+import type { Role } from '../types.js'\n+\n export const COMPOSER_PROMPT_GAP_WIDTH = 1\n \n let _seg: Intl.Segmenter | null = null","@@ -162,6 +164,14 @@ export function composerPromptWidth(promptText: string) {\n   return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH\n }\n \n+export function transcriptGutterWidth(role: Role, userPrompt: string) {\n+  return role === 'user' ? composerPromptWidth(userPrompt) : 3\n+}\n+\n+export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) {\n+  return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt) - 2)\n+}\n+\n export function stableComposerColumns(totalCols: number, promptWidth: number) {\n   // Physical render/wrap width. Always reserve outer composer padding and\n   // prompt prefix. Only reserve the transcript scrollbar gutter when the"]},{"file":"ui-tui/src/lib/virtualHeights.ts","hunks":["@@ -1,5 +1,6 @@\n import type { Msg } from '../types.js'\n \n+import { transcriptBodyWidth } from './inputMetrics.js'\n import { boundedHistoryRenderText } from './text.js'\n \n const hashText = (text: string) => {","@@ -38,7 +39,12 @@ export const wrappedLines = (text: string, width: number) => {\n export const estimatedMsgHeight = (\n   msg: Msg,\n   cols: number,\n-  { compact, details, limitHistory = false }: { compact: boolean; details: boolean; limitHistory?: boolean }\n+  {\n+    compact,\n+    details,\n+    limitHistory = false,\n+    userPrompt = ''\n+  }: { compact: boolean; details: boolean; limitHistory?: boolean; userPrompt?: string }\n ) => {\n   if (msg.kind === 'intro') {\n     return msg.info?.version ? 9 : 5","@@ -56,7 +62,7 @@ export const estimatedMsgHeight = (\n     return Math.max(2, msg.todos.length + 2)\n   }\n \n-  const bodyWidth = Math.max(20, cols - 5)\n+  const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt)\n   const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text\n   let h = wrappedLines(text || ' ', bodyWidth)\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}