mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(tui): preserve prompt separator width (#19340)
* fix(tui): preserve prompt separator width * fix(tui): align transcript height estimates with prompt width
This commit is contained in:
parent
d9c090fe36
commit
0ce1b9fe20
6 changed files with 85 additions and 7 deletions
|
|
@ -1,7 +1,13 @@
|
||||||
|
import { renderSync } from '@hermes/ink'
|
||||||
|
import React from 'react'
|
||||||
|
import { PassThrough } from 'stream'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { MessageLine } from '../components/messageLine.js'
|
||||||
import { toTranscriptMessages } from '../domain/messages.js'
|
import { toTranscriptMessages } from '../domain/messages.js'
|
||||||
import { upsert } from '../lib/messages.js'
|
import { upsert } from '../lib/messages.js'
|
||||||
|
import { stripAnsi } from '../lib/text.js'
|
||||||
|
import { DEFAULT_THEME } from '../theme.js'
|
||||||
|
|
||||||
describe('toTranscriptMessages', () => {
|
describe('toTranscriptMessages', () => {
|
||||||
it('preserves assistant tool-call rows so resume does not drop prior turns', () => {
|
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', () => {
|
describe('upsert', () => {
|
||||||
it('appends when last role differs', () => {
|
it('appends when last role differs', () => {
|
||||||
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)
|
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ describe('virtual height estimates', () => {
|
||||||
expect(estimatedMsgHeight(msg, 35, { compact: false, details: false })).toBeGreaterThan(5)
|
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', () => {
|
it('includes detail sections when visible', () => {
|
||||||
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }
|
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||||
|
import { composerPromptWidth } from '../lib/inputMetrics.js'
|
||||||
import { isMac } from '../lib/platform.js'
|
import { isMac } from '../lib/platform.js'
|
||||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||||
|
|
@ -244,7 +245,8 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
|
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
|
||||||
|
|
||||||
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
|
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(() => {
|
const heightCache = useMemo(() => {
|
||||||
let cache = heightCachesRef.current.get(heightCacheKey)
|
let cache = heightCachesRef.current.get(heightCacheKey)
|
||||||
|
|
@ -266,9 +268,10 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
|
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
|
||||||
compact: ui.compact,
|
compact: ui.compact,
|
||||||
details: detailsVisible,
|
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(
|
const syncHeightCache = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { LONG_MSG } from '../config/limits.js'
|
||||||
import { sectionMode } from '../domain/details.js'
|
import { sectionMode } from '../domain/details.js'
|
||||||
import { userDisplay } from '../domain/messages.js'
|
import { userDisplay } from '../domain/messages.js'
|
||||||
import { ROLE } from '../domain/roles.js'
|
import { ROLE } from '../domain/roles.js'
|
||||||
|
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
|
||||||
import {
|
import {
|
||||||
boundedHistoryRenderText,
|
boundedHistoryRenderText,
|
||||||
boundedLiveRenderText,
|
boundedLiveRenderText,
|
||||||
|
|
@ -95,6 +96,7 @@ export const MessageLine = memo(function MessageLine({
|
||||||
}
|
}
|
||||||
|
|
||||||
const { body, glyph, prefix } = ROLE[msg.role](t)
|
const { body, glyph, prefix } = ROLE[msg.role](t)
|
||||||
|
const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)
|
||||||
|
|
||||||
const showDetails =
|
const showDetails =
|
||||||
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
|
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
|
||||||
|
|
@ -163,13 +165,13 @@ export const MessageLine = memo(function MessageLine({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<NoSelect flexShrink={0} fromLeftEdge width={3}>
|
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
|
||||||
<Text bold={msg.role === 'user'} color={prefix}>
|
<Text bold={msg.role === 'user'} color={prefix}>
|
||||||
{glyph}{' '}
|
{glyph}{' '}
|
||||||
</Text>
|
</Text>
|
||||||
</NoSelect>
|
</NoSelect>
|
||||||
|
|
||||||
<Box width={Math.max(20, cols - 5)}>{content}</Box>
|
<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt)}>{content}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { stringWidth } from '@hermes/ink'
|
import { stringWidth } from '@hermes/ink'
|
||||||
|
|
||||||
|
import type { Role } from '../types.js'
|
||||||
|
|
||||||
export const COMPOSER_PROMPT_GAP_WIDTH = 1
|
export const COMPOSER_PROMPT_GAP_WIDTH = 1
|
||||||
|
|
||||||
let _seg: Intl.Segmenter | null = null
|
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
|
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) {
|
export function stableComposerColumns(totalCols: number, promptWidth: number) {
|
||||||
// Physical render/wrap width. Always reserve outer composer padding and
|
// Physical render/wrap width. Always reserve outer composer padding and
|
||||||
// prompt prefix. Only reserve the transcript scrollbar gutter when the
|
// prompt prefix. Only reserve the transcript scrollbar gutter when the
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Msg } from '../types.js'
|
import type { Msg } from '../types.js'
|
||||||
|
|
||||||
|
import { transcriptBodyWidth } from './inputMetrics.js'
|
||||||
import { boundedHistoryRenderText } from './text.js'
|
import { boundedHistoryRenderText } from './text.js'
|
||||||
|
|
||||||
const hashText = (text: string) => {
|
const hashText = (text: string) => {
|
||||||
|
|
@ -38,7 +39,12 @@ export const wrappedLines = (text: string, width: number) => {
|
||||||
export const estimatedMsgHeight = (
|
export const estimatedMsgHeight = (
|
||||||
msg: Msg,
|
msg: Msg,
|
||||||
cols: number,
|
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') {
|
if (msg.kind === 'intro') {
|
||||||
return msg.info?.version ? 9 : 5
|
return msg.info?.version ? 9 : 5
|
||||||
|
|
@ -56,7 +62,7 @@ export const estimatedMsgHeight = (
|
||||||
return Math.max(2, msg.todos.length + 2)
|
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
|
const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text
|
||||||
let h = wrappedLines(text || ' ', bodyWidth)
|
let h = wrappedLines(text || ' ', bodyWidth)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue