Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR
<tr><td><b>Research-ready</b></td><td>Batch trajectory generation, trajectory compression for training the next generation of tool-calling models.</td></tr>
</table>

<p align="center">
<img src="assets/subagent-status-animation.gif" alt="Hermes TUI showing linked nodes animating as the active subagent count changes" width="620">
<br>
<sub>Each linked node represents a running subagent and updates in place as delegated work progresses.</sub>
</p>

---

## Quick Install
Expand Down
Binary file added assets/subagent-status-animation.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 67 additions & 7 deletions ui-tui/src/__tests__/appChromeStatusRule.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from 'react'
import { PassThrough } from 'node:stream'

import { renderSync } from '@hermes/ink'
import React, { act } from 'react'
import stripAnsi from 'strip-ansi'
import { describe, expect, it, vi } from 'vitest'

import { StatusRule } from '../components/appChrome.js'
import { AnimatedSubagentNodes, StatusRule, subagentNodeFrame, subagentNodeStyle } from '../components/appChrome.js'
import { DEFAULT_THEME } from '../theme.js'

type ReactNodeLike = React.ReactNode
Expand Down Expand Up @@ -105,13 +109,68 @@ const baseProps = {
}

describe('StatusRule background-subagent indicator', () => {
it('renders ⛓ N on a wide terminal when subagents are running', () => {
it('uses one linked node per active subagent', () => {
expect(subagentNodeFrame(1, 0)).toBe('◌')
expect(subagentNodeFrame(3, 0)).toBe('◌─◔─◑')
})

it('advances every agent node with a subtle phase stagger', () => {
const first = subagentNodeFrame(4, 0).split('─')
const second = subagentNodeFrame(4, 1).split('─')

expect(second).toHaveLength(first.length)
expect(second.every((node, index) => node !== first[index])).toBe(true)
})

it('uses semantic theme colors as each node fills', () => {
expect(subagentNodeStyle('◌', DEFAULT_THEME)).toEqual({ color: DEFAULT_THEME.color.muted, dim: true })
expect(subagentNodeStyle('◑', DEFAULT_THEME)).toEqual({ color: DEFAULT_THEME.color.label })
expect(subagentNodeStyle('●', DEFAULT_THEME)).toEqual({ color: DEFAULT_THEME.color.accent })
expect(subagentNodeStyle('─', DEFAULT_THEME)).toEqual({ color: DEFAULT_THEME.color.border })
})

it('ticks only while the linked nodes are mounted', () => {
vi.useFakeTimers()
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
const stdin = new PassThrough()
const stdout = Object.assign(new PassThrough(), { columns: 40, rows: 4 })
let output = ''
stdout.on('data', chunk => (output += chunk.toString()))

const instance = renderSync(
<AnimatedSubagentNodes count={1} t={DEFAULT_THEME}>
{subagentNodeFrame(1, 0)}
</AnimatedSubagentNodes>,
{
exitOnCtrlC: false,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
}
)

act(() => vi.advanceTimersByTime(320))
instance.unmount()
instance.cleanup()

const rendered = stripAnsi(output)
expect(rendered).toContain('◌')
expect(rendered).toContain('◔')
expect(clearIntervalSpy).toHaveBeenCalled()

clearIntervalSpy.mockRestore()
vi.useRealTimers()
})

it('renders the active count as linked nodes instead of a number', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 3 }
})

expect(textContent(element)).toContain('⛓ 3')
const text = textContent(element)

expect(text).toContain('◌─◔─◑')
expect(text).not.toContain('⛓ 3')
})

it('omits the segment when no subagents are running', () => {
Expand All @@ -120,13 +179,13 @@ describe('StatusRule background-subagent indicator', () => {
usage: { ...baseProps.usage, active_subagents: 0 }
})

expect(textContent(element)).not.toContain('⛓')
expect(textContent(element)).not.toMatch(/[◌◔◑◕●]/)
})

it('omits the segment when the field is absent', () => {
const element = StatusRule({ ...baseProps })

expect(textContent(element)).not.toContain('⛓')
expect(textContent(element)).not.toMatch(/[◌◔◑◕●]/)
})

it('spells out the auto-resume hint when idle with subagents in flight', () => {
Expand All @@ -141,6 +200,7 @@ describe('StatusRule background-subagent indicator', () => {
it('pluralizes the resume hint for multiple in-flight subagents', () => {
const element = StatusRule({
...baseProps,
cols: 120,
usage: { ...baseProps.usage, active_subagents: 3 }
})

Expand Down Expand Up @@ -175,7 +235,7 @@ describe('StatusRule background-subagent indicator', () => {
usage: { ...baseProps.usage, active_subagents: 2 }
})

expect(textContent(element)).not.toContain('⛓')
expect(textContent(element)).not.toMatch(/[◌◔◑◕●]/)
})
})

Expand Down
70 changes: 65 additions & 5 deletions ui-tui/src/components/appChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,43 @@ import type { Msg, Usage } from '../types.js'
import { scrollbarColors } from './overlayPrimitives.js'

const FACE_TICK_MS = 2500
const SUBAGENT_FRAME_MS = 320
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
const SUBAGENT_NODE_CYCLE = ['◌', '◔', '◑', '◕', '●', '◕', '◑', '◔'] as const
const SUBAGENT_NODE_OFFSETS = [0, 1, 2, 1, 0, 1, 2, 1] as const

export function subagentNodeFrame(count: number, tick: number) {
return Array.from({ length: Math.max(0, Math.floor(count)) }, (_, index) => {
const phase = (SUBAGENT_NODE_OFFSETS[index % SUBAGENT_NODE_OFFSETS.length] + tick) % SUBAGENT_NODE_CYCLE.length

return SUBAGENT_NODE_CYCLE[phase]
}).join('─')
}

export function subagentNodeStyle(glyph: string, t: Theme): { color: string; dim?: boolean } {
switch (glyph) {
case '◌':
return { color: t.color.muted, dim: true }

case '◔':
return { color: t.color.muted }

case '◑':
return { color: t.color.label }

case '◕':
return { color: t.color.primary }

case '●':
return { color: t.color.accent }

case '─':
return { color: t.color.border }

default:
return { color: t.color.muted }
}
}

// Keep verb segment width stable so status-bar content to the right doesn't
// jitter when the ticker rotates between short/long verbs.
Expand Down Expand Up @@ -432,6 +468,29 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
return <Text color={color}>♥</Text>
}

export function AnimatedSubagentNodes({ children, count, t }: { children: string; count: number; t: Theme }) {
const [tick, setTick] = useState(0)

useEffect(() => {
const id = setInterval(() => setTick(current => current + 1), SUBAGENT_FRAME_MS)

return () => clearInterval(id)
}, [])

const frame = tick === 0 ? children : subagentNodeFrame(count, tick)

return (
<Box flexShrink={0}>
<Text color={t.color.muted}>{' │ '}</Text>
{[...frame].map((glyph, index) => (
<Box flexShrink={0} key={index}>
<Text {...subagentNodeStyle(glyph, t)}>{glyph}</Text>
</Box>
))}
</Box>
)
}

export function StatusRule({
battery,
focusView,
Expand Down Expand Up @@ -555,14 +614,15 @@ export function StatusRule({
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0
const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`))
const subagentFrame = subagentNodeFrame(subagentCount, 0)
const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(subagentFrame))

// Parked-background reassurance: a top-level delegate_task runs in the
// background, so the turn ends (idle) while the subagent keeps working and its
// result re-enters as a fresh turn later. When idle with work still in flight,
// spell out that the agent resumes on its own — no spinner, nothing to poll.
// Width-budgeted like every tail segment, so it drops first on a tight
// terminal where already carries the signal.
// terminal where the linked nodes already carry the signal.
const resumeHintText =
subagentCount === 1 ? '↩ resumes when subagent finishes' : `↩ resumes when ${subagentCount} subagents finish`

Expand Down Expand Up @@ -691,9 +751,9 @@ export function StatusRule({
</Text>
) : null}
{showSubagents ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}⛓ {subagentCount}
</Text>
<AnimatedSubagentNodes count={subagentCount} t={t}>
{subagentFrame}
</AnimatedSubagentNodes>
) : null}
{showResumeHint ? (
<Text color={t.color.muted} dim wrap="truncate-end">
Expand Down
Loading