mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(tui): inline todo in transcript, group across thinking
This commit is contained in:
parent
4943ea2a7c
commit
319c1c1691
6 changed files with 104 additions and 30 deletions
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from '../app/delegationStore.js'
|
} from '../app/delegationStore.js'
|
||||||
import { patchOverlayState } from '../app/overlayStore.js'
|
import { patchOverlayState } from '../app/overlayStore.js'
|
||||||
import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js'
|
import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js'
|
||||||
import { $turnState } from '../app/turnStore.js'
|
import { useTurnSelector } from '../app/turnStore.js'
|
||||||
import type { GatewayClient } from '../gatewayClient.js'
|
import type { GatewayClient } from '../gatewayClient.js'
|
||||||
import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gatewayTypes.js'
|
import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gatewayTypes.js'
|
||||||
import { asRpcResult } from '../lib/rpc.js'
|
import { asRpcResult } from '../lib/rpc.js'
|
||||||
|
|
@ -683,7 +683,7 @@ function DiffView({
|
||||||
// ── Main overlay ─────────────────────────────────────────────────────
|
// ── Main overlay ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) {
|
export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) {
|
||||||
const turn = useStore($turnState)
|
const liveSubagents = useTurnSelector(state => state.subagents)
|
||||||
const delegation = useStore($delegationState)
|
const delegation = useStore($delegationState)
|
||||||
const history = useStore($spawnHistory)
|
const history = useStore($spawnHistory)
|
||||||
const diffPair = useStore($spawnDiff)
|
const diffPair = useStore($spawnDiff)
|
||||||
|
|
@ -705,17 +705,17 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||||
const [mode, setMode] = useState<'detail' | 'list'>('list')
|
const [mode, setMode] = useState<'detail' | 'list'>('list')
|
||||||
|
|
||||||
const detailScrollRef = useRef<null | ScrollBoxHandle>(null)
|
const detailScrollRef = useRef<null | ScrollBoxHandle>(null)
|
||||||
const prevLiveCountRef = useRef(turn.subagents.length)
|
const prevLiveCountRef = useRef(liveSubagents.length)
|
||||||
|
|
||||||
// ── Derived state ──────────────────────────────────────────────────
|
// ── Derived state ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeSnapshot = historyIndex > 0 ? history[historyIndex - 1] : null
|
const activeSnapshot = historyIndex > 0 ? history[historyIndex - 1] : null
|
||||||
// Instant fallback to history[0] the moment the live list clears — avoids
|
// Instant fallback to history[0] the moment the live list clears — avoids
|
||||||
// a one-frame "no subagents" flash while the auto-follow effect fires.
|
// a one-frame "no subagents" flash while the auto-follow effect fires.
|
||||||
const justFinishedSnapshot = historyIndex === 0 && turn.subagents.length === 0 ? (history[0] ?? null) : null
|
const justFinishedSnapshot = historyIndex === 0 && liveSubagents.length === 0 ? (history[0] ?? null) : null
|
||||||
const effectiveSnapshot = activeSnapshot ?? justFinishedSnapshot
|
const effectiveSnapshot = activeSnapshot ?? justFinishedSnapshot
|
||||||
const replayMode = effectiveSnapshot != null
|
const replayMode = effectiveSnapshot != null
|
||||||
const subagents = replayMode ? effectiveSnapshot.subagents : turn.subagents
|
const subagents = replayMode ? effectiveSnapshot.subagents : liveSubagents
|
||||||
|
|
||||||
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
||||||
const totals = useMemo(() => treeTotals(tree), [tree])
|
const totals = useMemo(() => treeTotals(tree), [tree])
|
||||||
|
|
@ -753,14 +753,14 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
|
||||||
// dropped into an empty live view. Fires only when transitioning from
|
// dropped into an empty live view. Fires only when transitioning from
|
||||||
// "had live subagents" → "live empty" while in live mode.
|
// "had live subagents" → "live empty" while in live mode.
|
||||||
const prev = prevLiveCountRef.current
|
const prev = prevLiveCountRef.current
|
||||||
prevLiveCountRef.current = turn.subagents.length
|
prevLiveCountRef.current = liveSubagents.length
|
||||||
|
|
||||||
if (historyIndex === 0 && prev > 0 && turn.subagents.length === 0 && history.length > 0) {
|
if (historyIndex === 0 && prev > 0 && liveSubagents.length === 0 && history.length > 0) {
|
||||||
setHistoryIndex(1)
|
setHistoryIndex(1)
|
||||||
setCursor(0)
|
setCursor(0)
|
||||||
setFlash('turn finished · inspect freely · q to close')
|
setFlash('turn finished · inspect freely · q to close')
|
||||||
}
|
}
|
||||||
}, [history.length, historyIndex, turn.subagents.length])
|
}, [history.length, historyIndex, liveSubagents.length])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset detail scroll on navigation so the top of the new node shows.
|
// Reset detail scroll on navigation so the top of the new node shows.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'
|
||||||
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
|
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { $delegationState } from '../app/delegationStore.js'
|
import { $delegationState } from '../app/delegationStore.js'
|
||||||
import { $turnState } from '../app/turnStore.js'
|
import { useTurnSelector } from '../app/turnStore.js'
|
||||||
import { FACES } from '../content/faces.js'
|
import { FACES } from '../content/faces.js'
|
||||||
import { VERBS } from '../content/verbs.js'
|
import { VERBS } from '../content/verbs.js'
|
||||||
import { fmtDuration } from '../domain/messages.js'
|
import { fmtDuration } from '../domain/messages.js'
|
||||||
|
|
@ -69,9 +69,9 @@ function SpawnHud({ t }: { t: Theme }) {
|
||||||
// Tight HUD that only appears when the session is actually fanning out.
|
// Tight HUD that only appears when the session is actually fanning out.
|
||||||
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
||||||
const delegation = useStore($delegationState)
|
const delegation = useStore($delegationState)
|
||||||
const turn = useStore($turnState)
|
const subagents = useTurnSelector(state => state.subagents)
|
||||||
|
|
||||||
const tree = useMemo(() => buildSubagentTree(turn.subagents), [turn.subagents])
|
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
||||||
const totals = useMemo(() => treeTotals(tree), [tree])
|
const totals = useMemo(() => treeTotals(tree), [tree])
|
||||||
|
|
||||||
if (!totals.descendantCount && !delegation.paused) {
|
if (!totals.descendantCount && !delegation.paused) {
|
||||||
|
|
|
||||||
|
|
@ -66,13 +66,11 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||||
progress={progress}
|
progress={progress}
|
||||||
sections={ui.sections}
|
sections={ui.sections}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LiveTodoPanel />
|
||||||
</Box>
|
</Box>
|
||||||
</ScrollBox>
|
</ScrollBox>
|
||||||
|
|
||||||
<Box flexDirection="column" flexShrink={0} paddingX={1}>
|
|
||||||
<LiveTodoPanel />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<NoSelect flexShrink={0} marginLeft={1}>
|
<NoSelect flexShrink={0} marginLeft={1}>
|
||||||
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
|
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
|
||||||
</NoSelect>
|
</NoSelect>
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,14 @@ import { memo } from 'react'
|
||||||
import type { AppLayoutProgressProps } from '../app/interfaces.js'
|
import type { AppLayoutProgressProps } from '../app/interfaces.js'
|
||||||
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js'
|
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js'
|
||||||
import { $uiState } from '../app/uiStore.js'
|
import { $uiState } from '../app/uiStore.js'
|
||||||
|
import { appendToolShelfMessage } from '../lib/liveProgress.js'
|
||||||
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
|
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||||
|
|
||||||
import { MessageLine } from './messageLine.js'
|
import { MessageLine } from './messageLine.js'
|
||||||
import { TodoPanel } from './todoPanel.js'
|
import { TodoPanel } from './todoPanel.js'
|
||||||
|
|
||||||
const isToolOnly = (msg: Msg | undefined) =>
|
const groupedSegments = (segments: Msg[]): Msg[] =>
|
||||||
Boolean(msg && msg.kind === 'trail' && !msg.thinking?.trim() && !msg.text && msg.tools?.length)
|
segments.reduce<Msg[]>((acc, msg) => appendToolShelfMessage(acc, msg), [])
|
||||||
|
|
||||||
const groupedSegments = (segments: Msg[]) =>
|
|
||||||
segments.reduce<Msg[]>((acc, msg) => {
|
|
||||||
if (isToolOnly(msg) && isToolOnly(acc.at(-1))) {
|
|
||||||
const prev = acc.at(-1)!
|
|
||||||
|
|
||||||
return [...acc.slice(0, -1), { ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...acc, msg]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
export const StreamingAssistant = memo(function StreamingAssistant({
|
export const StreamingAssistant = memo(function StreamingAssistant({
|
||||||
cols,
|
cols,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Msg } from '../types.js'
|
||||||
|
|
||||||
import { appendToolShelfMessage, canHoldToolShelf, isTodoDone, mergeToolShelfInto } from './liveProgress.js'
|
import { appendToolShelfMessage, canHoldToolShelf, isTodoDone, mergeToolShelfInto } from './liveProgress.js'
|
||||||
|
|
||||||
describe('isTodoDone', () => {
|
describe('isTodoDone', () => {
|
||||||
|
|
@ -54,6 +56,52 @@ describe('appendToolShelfMessage', () => {
|
||||||
expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }])
|
expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('merges through intervening thinking-only rows back into the nearest holder', () => {
|
||||||
|
const prev: Msg[] = [
|
||||||
|
{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] },
|
||||||
|
{ kind: 'trail', role: 'system', text: '', thinking: 'more plan' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const merged = appendToolShelfMessage(prev, {
|
||||||
|
kind: 'trail',
|
||||||
|
role: 'system',
|
||||||
|
text: '',
|
||||||
|
tools: ['two ✓']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(merged).toHaveLength(2)
|
||||||
|
expect(merged[0]).toEqual({
|
||||||
|
kind: 'trail',
|
||||||
|
role: 'system',
|
||||||
|
text: '',
|
||||||
|
thinking: 'plan',
|
||||||
|
tools: ['one ✓', 'two ✓']
|
||||||
|
})
|
||||||
|
expect(merged[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses a chronological thinking/tool/thinking/tool stream into one shelf', () => {
|
||||||
|
const events: Msg[] = [
|
||||||
|
{ kind: 'trail', role: 'system', text: '', thinking: 'plan' },
|
||||||
|
{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] },
|
||||||
|
{ kind: 'trail', role: 'system', text: '', thinking: 'more plan' },
|
||||||
|
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] },
|
||||||
|
{ kind: 'trail', role: 'system', text: '', tools: ['three ✓'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const reduced = events.reduce<Msg[]>((acc, msg) => appendToolShelfMessage(acc, msg), [])
|
||||||
|
|
||||||
|
expect(reduced).toHaveLength(2)
|
||||||
|
expect(reduced[0]).toEqual({
|
||||||
|
kind: 'trail',
|
||||||
|
role: 'system',
|
||||||
|
text: '',
|
||||||
|
thinking: 'plan',
|
||||||
|
tools: ['one ✓', 'two ✓', 'three ✓']
|
||||||
|
})
|
||||||
|
expect(reduced[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' })
|
||||||
|
})
|
||||||
|
|
||||||
it('starts a new shelf across assistant text boundaries', () => {
|
it('starts a new shelf across assistant text boundaries', () => {
|
||||||
const merged = appendToolShelfMessage(
|
const merged = appendToolShelfMessage(
|
||||||
[{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, { role: 'assistant', text: 'done' }],
|
[{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, { role: 'assistant', text: 'done' }],
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,41 @@ export const mergeToolShelfInto = (target: Msg, source: Msg): Msg => ({
|
||||||
tools: [...(target.tools ?? []), ...(source.tools ?? [])]
|
tools: [...(target.tools ?? []), ...(source.tools ?? [])]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isBarrierMessage = (msg: Msg | undefined) => {
|
||||||
|
if (!msg) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant text, user input, intro/panel rows all terminate the shelf.
|
||||||
|
if (msg.kind === 'intro' || msg.kind === 'panel' || msg.kind === 'diff') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role && msg.role !== 'system') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isToolCarryingTrail = (msg: Msg | undefined) =>
|
||||||
|
Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length)
|
||||||
|
|
||||||
export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => {
|
export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => {
|
||||||
if (!isToolShelfMessage(msg)) {
|
if (!isToolShelfMessage(msg)) {
|
||||||
return [...prev, msg]
|
return [...prev, msg]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fallbackHolder: number | null = null
|
||||||
|
|
||||||
for (let index = prev.length - 1; index >= 0; index--) {
|
for (let index = prev.length - 1; index >= 0; index--) {
|
||||||
const candidate = prev[index]
|
const candidate = prev[index]
|
||||||
|
|
||||||
if (canHoldToolShelf(candidate)) {
|
if (isToolCarryingTrail(candidate)) {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
|
|
||||||
next[index] = mergeToolShelfInto(candidate!, msg)
|
next[index] = mergeToolShelfInto(candidate!, msg)
|
||||||
|
|
@ -30,10 +56,22 @@ export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] =>
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidate?.kind !== 'trail' || candidate.text) {
|
if (fallbackHolder === null && canHoldToolShelf(candidate)) {
|
||||||
|
fallbackHolder = index
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBarrierMessage(candidate)) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallbackHolder !== null) {
|
||||||
|
const next = [...prev]
|
||||||
|
|
||||||
|
next[fallbackHolder] = mergeToolShelfInto(prev[fallbackHolder]!, msg)
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
return [...prev, msg]
|
return [...prev, msg]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue