mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
Two TUI polish fixes.
(1) Right-click copy now clears the highlight.
The right-click handler copied an active selection via onCopySelectionNoClear
(the copy-on-select variant that keeps the highlight during a drag) and never
cleared it, so after right-click-to-copy the selection stayed lit with no
confirmation and a follow-up right-click re-copied the stale range instead of
pasting. A successful right-click copy now clears the selection and notifies;
if the copy fails (no clipboard path) the highlight survives and we fall back
to the right-click paste handler, exactly as before.
(2) Group transcript blocks so boundaries read clearly.
Model replies, reasoning/tool trails, and system/error notes rendered with no
vertical separation, so distinct block types butted together and were hard to
scan. Group adjacent blocks by kind: one blank line opens only where the visual
group changes (model prose <-> reasoning/tool trails <-> notes), while a run of
same-kind blocks renders flush. The rule lives in domain/blockLayout.ts
(messageGroup + hasLeadGap) and is applied intrinsically in MessageLine via a
`prev` prop, which fixes the things ad-hoc per-block margins kept breaking:
- Streaming stability: the gap is derived from the stable predecessor, never
the live block's own changing text, so the actively-streaming reply computes
the same gap while it streams as the settled segment does once it flushes.
No reflow/jump.
- Transparent empty trails: a trail hidden by /details, or one carrying only a
token tally (the finalDetails segment message.complete appends), renders
nothing and is transparent to grouping (prevRenderedMsg skips it), so there
are no floating gaps, no doubled gap after a prompt, and no padded space
above the final reply. In the default/collapsed modes content-bearing trails
always render, so the grouping is a no-op there.
The virtual-height estimator counts the group-boundary line so scroll math
stays accurate before Yoga remeasures.
ui-tui/src/domain/blockLayout.ts (new), components/messageLine.tsx,
components/streamingAssistant.tsx, components/appLayout.tsx,
lib/virtualHeights.ts, app/useMainApp.ts.
Tests: blockLayout.test.ts (grouping + hidden/empty-trail visibility),
virtualHeights leadGap, app-mouse.test.ts copy behavior. Full ui-tui suite
green apart from 3 pre-existing local/env failures (cursorDrift, ink-resize,
virtualHeights user-prompt-width) unchanged from main.
122 lines
5.1 KiB
TypeScript
122 lines
5.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { blockRenders, hasLeadGap, messageGroup, prevRenderedMsg } from '../domain/blockLayout.js'
|
|
import type { Msg } from '../types.js'
|
|
|
|
const m = (over: Partial<Msg>): Msg => ({ role: 'assistant', text: '', ...over })
|
|
|
|
describe('messageGroup', () => {
|
|
it('classifies each block kind into its visual band', () => {
|
|
expect(messageGroup(m({ role: 'assistant' }))).toBe('model')
|
|
expect(messageGroup(m({ role: 'assistant', kind: 'diff' }))).toBe('diff')
|
|
expect(messageGroup(m({ role: 'system', kind: 'trail' }))).toBe('trail')
|
|
expect(messageGroup(m({ role: 'system' }))).toBe('note')
|
|
expect(messageGroup(m({ role: 'user' }))).toBe('user')
|
|
expect(messageGroup(m({ role: 'user', kind: 'slash' }))).toBe('slash')
|
|
expect(messageGroup(m({ role: 'system', kind: 'intro' }))).toBe('intro')
|
|
expect(messageGroup(m({ role: 'system', kind: 'panel' }))).toBe('intro')
|
|
})
|
|
})
|
|
|
|
describe('hasLeadGap', () => {
|
|
const trail = m({ role: 'system', kind: 'trail' })
|
|
const model = m({ role: 'assistant' })
|
|
const note = m({ role: 'system' })
|
|
const user = m({ role: 'user' })
|
|
const diff = m({ role: 'assistant', kind: 'diff' })
|
|
const slash = m({ role: 'user', kind: 'slash' })
|
|
|
|
it('opens a gap only at a boundary between working-area groups', () => {
|
|
expect(hasLeadGap(trail, model)).toBe(true)
|
|
expect(hasLeadGap(model, trail)).toBe(true)
|
|
expect(hasLeadGap(model, note)).toBe(true)
|
|
expect(hasLeadGap(note, model)).toBe(true)
|
|
})
|
|
|
|
it('keeps same-group neighbours flush (the grouping)', () => {
|
|
expect(hasLeadGap(trail, trail)).toBe(false)
|
|
expect(hasLeadGap(model, model)).toBe(false)
|
|
expect(hasLeadGap(note, note)).toBe(false)
|
|
})
|
|
|
|
it('never gaps the first block (no predecessor)', () => {
|
|
expect(hasLeadGap(undefined, model)).toBe(false)
|
|
expect(hasLeadGap(undefined, trail)).toBe(false)
|
|
})
|
|
|
|
it('suppresses the gap after blocks that already paint a trailing line', () => {
|
|
// user and diff carry their own marginBottom — the following block must
|
|
// not add a second blank line on top of it.
|
|
expect(hasLeadGap(user, trail)).toBe(false)
|
|
expect(hasLeadGap(user, model)).toBe(false)
|
|
expect(hasLeadGap(diff, model)).toBe(false)
|
|
})
|
|
|
|
it('still gaps after a slash echo (it has no trailing margin)', () => {
|
|
expect(hasLeadGap(slash, model)).toBe(true)
|
|
expect(hasLeadGap(slash, trail)).toBe(true)
|
|
})
|
|
|
|
it('lets user / slash / diff own their spacing (never managed here)', () => {
|
|
expect(hasLeadGap(model, user)).toBe(false)
|
|
expect(hasLeadGap(model, slash)).toBe(false)
|
|
expect(hasLeadGap(model, diff)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('blockRenders', () => {
|
|
const trail: Msg = { role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }
|
|
const model: Msg = { role: 'assistant', text: 'hi' }
|
|
const todos: Msg = { role: 'system', kind: 'trail', text: '', todos: [{ content: 'a', id: '1', status: 'pending' }] }
|
|
|
|
it('always renders non-trail blocks', () => {
|
|
expect(blockRenders(model, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
|
})
|
|
|
|
it('renders a content-bearing trail unless every section is hidden', () => {
|
|
expect(blockRenders(trail, { detailsMode: 'collapsed' })).toBe(true)
|
|
expect(blockRenders(trail, { detailsMode: 'expanded' })).toBe(true)
|
|
// /details hidden routes through commandOverride, which hides every section.
|
|
expect(blockRenders(trail, { detailsMode: 'hidden', commandOverride: true })).toBe(false)
|
|
})
|
|
|
|
it('does not render a content-less trail (e.g. finalDetails with only a token tally)', () => {
|
|
const tally: Msg = { role: 'system', kind: 'trail', text: '', toolTokens: 40 }
|
|
|
|
expect(blockRenders(tally, { detailsMode: 'expanded' })).toBe(false)
|
|
})
|
|
|
|
it('keeps todo trails visible even when details are hidden', () => {
|
|
expect(blockRenders(todos, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('prevRenderedMsg', () => {
|
|
const hiddenCtx = { commandOverride: true, detailsMode: 'hidden' as const }
|
|
const shownCtx = { detailsMode: 'collapsed' as const }
|
|
|
|
const rows: Msg[] = [
|
|
{ role: 'user', text: 'q' }, // 0
|
|
{ role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }, // 1
|
|
{ role: 'assistant', text: 'first' }, // 2
|
|
{ role: 'system', kind: 'trail', text: '', tools: ['Edit bar.ts'] }, // 3
|
|
{ role: 'assistant', text: 'second' } // 4
|
|
]
|
|
const at = (i: number) => rows[i]
|
|
|
|
it('returns the literal predecessor when everything renders', () => {
|
|
expect(prevRenderedMsg(at, 2, shownCtx)).toBe(rows[1])
|
|
expect(prevRenderedMsg(at, 4, shownCtx)).toBe(rows[3])
|
|
})
|
|
|
|
it('skips hidden trails so grouping sees the nearest visible block', () => {
|
|
// With trails hidden, the prose at index 2 groups against the user (not the
|
|
// invisible trail) and the prose at index 4 groups against the prose at 2.
|
|
expect(prevRenderedMsg(at, 2, hiddenCtx)).toBe(rows[0])
|
|
expect(prevRenderedMsg(at, 4, hiddenCtx)).toBe(rows[2])
|
|
})
|
|
|
|
it('returns undefined at the top of the transcript', () => {
|
|
expect(prevRenderedMsg(at, 0, shownCtx)).toBeUndefined()
|
|
})
|
|
})
|