From 10fcd620d274ebcc073efbdb1d15a5efe175d522 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Apr 2026 15:25:06 -0500 Subject: [PATCH 1/3] fix(tui): render explicit prompt gap Reserve the composer prompt gap as layout instead of relying on terminal handling of trailing spaces. --- ui-tui/src/__tests__/textInputWrap.test.ts | 8 +++- ui-tui/src/components/appLayout.tsx | 47 ++++++++++++++++++---- ui-tui/src/lib/inputMetrics.ts | 4 ++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts index 5521012e9..e3ab5d0b3 100644 --- a/ui-tui/src/__tests__/textInputWrap.test.ts +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { offsetFromPosition } from '../components/textInput.js' -import { cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' +import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' describe('cursorLayout — char-wrap parity with wrap-ansi', () => { it('places cursor mid-line at its column', () => { @@ -42,6 +42,12 @@ describe('input metrics helpers', () => { expect(inputVisualHeight('one\ntwo', 40)).toBe(2) }) + it('counts the prompt gap as its own cell', () => { + expect(composerPromptWidth('>')).toBe(2) + expect(composerPromptWidth('❯')).toBe(2) + expect(composerPromptWidth('Ψ >')).toBe(4) + }) + it('reserves gutters on wide panes without starving narrow composer width', () => { expect(stableComposerColumns(100, 3)).toBe(93) expect(stableComposerColumns(100, 5)).toBe(91) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index f97cc17e6..bd78c31f4 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -9,7 +9,7 @@ import { $uiState } from '../app/uiStore.js' import { INLINE_MODE, SHOW_FPS } from '../config/env.js' import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js' import { PLACEHOLDER } from '../content/placeholders.js' -import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' +import { composerPromptWidth, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' import { PerfPane } from '../lib/perfPane.js' import { AgentsOverlay } from './agentsOverlay.js' @@ -22,6 +22,33 @@ import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' import { TextInput, type TextInputMouseApi } from './textInput.js' +const PROMPT_GAP_WIDTH = 1 + +const PromptPrefix = memo(function PromptPrefix({ + bold = false, + color, + promptText, + width +}: { + bold?: boolean + color: string + promptText: string + width: number +}) { + const glyphWidth = Math.max(1, stringWidth(promptText)) + + return ( + + + + {promptText} + + + + + ) +}) + const TranscriptPane = memo(function TranscriptPane({ actions, composer, @@ -125,8 +152,8 @@ const ComposerPane = memo(function ComposerPane({ const isBlocked = useStore($isBlocked) const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') const promptText = sh ? '$' : ui.theme.brand.prompt - const promptLabel = `${promptText} ` - const promptWidth = Math.max(1, stringWidth(promptLabel)) + const promptWidth = composerPromptWidth(promptText) + const promptBlank = ' '.repeat(promptWidth) const inputColumns = stableComposerColumns(composer.cols, promptWidth) const inputHeight = inputVisualHeight(composer.input, inputColumns) const inputMouseRef = useRef(null) @@ -217,7 +244,11 @@ const ComposerPane = memo(function ComposerPane({ {composer.inputBuf.map((line, i) => ( - {i === 0 ? promptLabel : ' '.repeat(promptWidth)} + {i === 0 ? ( + + ) : ( + {promptBlank} + )} {line || ' '} @@ -232,11 +263,11 @@ const ComposerPane = memo(function ComposerPane({ > {sh ? ( - {promptLabel} + + ) : composer.inputBuf.length ? ( + {promptBlank} ) : ( - - {composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel} - + )} diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index d54f96370..f110221a5 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -53,6 +53,10 @@ export function inputVisualHeight(value: string, columns: number) { return cursorLayout(value, value.length, columns).line + 1 } +export function composerPromptWidth(promptText: string) { + return Math.max(1, stringWidth(promptText)) + 1 +} + 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 From d3ab2b2e1343be254aab26509a3d653f9d11c33c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Apr 2026 15:50:54 -0500 Subject: [PATCH 2/3] fix(tui): share composer prompt gap metric Use one exported prompt gap constant for both composer width math and prompt prefix rendering. --- ui-tui/src/components/appLayout.tsx | 13 ++++++++----- ui-tui/src/lib/inputMetrics.ts | 4 +++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index bd78c31f4..641b33430 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -9,7 +9,12 @@ import { $uiState } from '../app/uiStore.js' import { INLINE_MODE, SHOW_FPS } from '../config/env.js' import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js' import { PLACEHOLDER } from '../content/placeholders.js' -import { composerPromptWidth, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' +import { + COMPOSER_PROMPT_GAP_WIDTH, + composerPromptWidth, + inputVisualHeight, + stableComposerColumns +} from '../lib/inputMetrics.js' import { PerfPane } from '../lib/perfPane.js' import { AgentsOverlay } from './agentsOverlay.js' @@ -22,8 +27,6 @@ import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' import { TextInput, type TextInputMouseApi } from './textInput.js' -const PROMPT_GAP_WIDTH = 1 - const PromptPrefix = memo(function PromptPrefix({ bold = false, color, @@ -35,7 +38,7 @@ const PromptPrefix = memo(function PromptPrefix({ promptText: string width: number }) { - const glyphWidth = Math.max(1, stringWidth(promptText)) + const glyphWidth = Math.max(1, width - COMPOSER_PROMPT_GAP_WIDTH) return ( @@ -44,7 +47,7 @@ const PromptPrefix = memo(function PromptPrefix({ {promptText} - + ) }) diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index f110221a5..3d824be3e 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -1,5 +1,7 @@ import { stringWidth } from '@hermes/ink' +export const COMPOSER_PROMPT_GAP_WIDTH = 1 + let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) @@ -54,7 +56,7 @@ export function inputVisualHeight(value: string, columns: number) { } export function composerPromptWidth(promptText: string) { - return Math.max(1, stringWidth(promptText)) + 1 + return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH } export function stableComposerColumns(totalCols: number, promptWidth: number) { From 8652d47eaa6d676302951360904b9670fb5e0eeb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Apr 2026 16:04:22 -0500 Subject: [PATCH 3/3] fix(tui): remove unused prompt import Drop the stale stringWidth import after centralizing composer prompt width metrics. --- ui-tui/src/components/appLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 641b33430..6f2d33df9 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,4 +1,4 @@ -import { AlternateScreen, Box, NoSelect, ScrollBox, stringWidth, Text } from '@hermes/ink' +import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { Fragment, memo, useMemo, useRef } from 'react'