mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +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.
118 lines
4 KiB
TypeScript
118 lines
4 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { memo } from 'react'
|
|
|
|
import type { AppLayoutProgressProps } from '../app/interfaces.js'
|
|
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js'
|
|
import { $uiState } from '../app/uiStore.js'
|
|
import { blockRenders } from '../domain/blockLayout.js'
|
|
import { appendToolShelfMessage } from '../lib/liveProgress.js'
|
|
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
|
|
|
|
import { MessageLine } from './messageLine.js'
|
|
import { TodoPanel } from './todoPanel.js'
|
|
|
|
const groupedSegments = (segments: Msg[]): Msg[] =>
|
|
segments.reduce<Msg[]>((acc, msg) => appendToolShelfMessage(acc, msg), [])
|
|
|
|
interface LiveBlock {
|
|
isStreaming?: boolean
|
|
key: string
|
|
msg: Msg
|
|
tools?: ActiveTool[]
|
|
}
|
|
|
|
export const StreamingAssistant = memo(function StreamingAssistant({
|
|
cols,
|
|
compact,
|
|
detailsMode,
|
|
detailsModeCommandOverride,
|
|
prevMsg,
|
|
progress,
|
|
sections
|
|
}: StreamingAssistantProps) {
|
|
const ui = useStore($uiState)
|
|
const streamSegments = useTurnSelector(state => state.streamSegments)
|
|
const streamPendingTools = useTurnSelector(state => state.streamPendingTools)
|
|
const streaming = useTurnSelector(state => state.streaming)
|
|
const activeTools = useTurnSelector(state => state.tools)
|
|
const showStreamingArea = Boolean(streaming)
|
|
|
|
if (!progress.showProgressArea && !showStreamingArea && !activeTools.length) {
|
|
return null
|
|
}
|
|
|
|
// Flatten the live area into one ordered list so each block's leading gap
|
|
// can be derived from the block directly above it — including the boundary
|
|
// back into settled history (prevMsg). Tracking the predecessor rather than
|
|
// the live text is what keeps the streaming block from jumping when it
|
|
// flushes into a settled segment.
|
|
const blocks: LiveBlock[] = groupedSegments(streamSegments).map((msg, i) => ({ key: `seg:${i}`, msg }))
|
|
|
|
if (activeTools.length) {
|
|
blocks.push({ key: 'active-tools', msg: { kind: 'trail', role: 'system', text: '' }, tools: activeTools })
|
|
}
|
|
|
|
if (showStreamingArea) {
|
|
blocks.push({
|
|
isStreaming: true,
|
|
key: 'streaming',
|
|
msg: { role: 'assistant', text: streaming, ...(streamPendingTools.length && { tools: streamPendingTools }) }
|
|
})
|
|
} else if (streamPendingTools.length) {
|
|
blocks.push({ key: 'pending-tools', msg: { kind: 'trail', role: 'system', text: '', tools: streamPendingTools } })
|
|
}
|
|
|
|
const detailsCtx = { commandOverride: detailsModeCommandOverride, detailsMode, sections }
|
|
let prev = prevMsg
|
|
|
|
return (
|
|
<>
|
|
{blocks.map(block => {
|
|
const node = (
|
|
<MessageLine
|
|
cols={cols}
|
|
compact={compact}
|
|
detailsMode={detailsMode}
|
|
detailsModeCommandOverride={detailsModeCommandOverride}
|
|
isStreaming={block.isStreaming}
|
|
key={block.key}
|
|
msg={block.msg}
|
|
prev={prev}
|
|
sections={sections}
|
|
t={ui.theme}
|
|
{...(block.tools ? { tools: block.tools } : {})}
|
|
/>
|
|
)
|
|
|
|
// Advance the grouping predecessor only past blocks that actually
|
|
// paint, so a trail hidden by /details stays transparent here too
|
|
// (active tools live in the prop, so fold them into the check).
|
|
const checkMsg = block.tools?.length ? { ...block.msg, tools: block.tools.map(tool => tool.name) } : block.msg
|
|
|
|
if (blockRenders(checkMsg, detailsCtx)) {
|
|
prev = block.msg
|
|
}
|
|
|
|
return node
|
|
})}
|
|
</>
|
|
)
|
|
})
|
|
|
|
export const LiveTodoPanel = memo(function LiveTodoPanel() {
|
|
const ui = useStore($uiState)
|
|
const todos = useTurnSelector(state => state.todos)
|
|
const collapsed = useTurnSelector(state => state.todoCollapsed)
|
|
|
|
return <TodoPanel collapsed={collapsed} onToggle={toggleTodoCollapsed} t={ui.theme} todos={todos} />
|
|
})
|
|
|
|
interface StreamingAssistantProps {
|
|
cols: number
|
|
compact?: boolean
|
|
detailsMode: DetailsMode
|
|
detailsModeCommandOverride: boolean
|
|
prevMsg?: Msg
|
|
progress: AppLayoutProgressProps
|
|
sections?: SectionVisibility
|
|
}
|