550 lines
16 KiB
TypeScript
550 lines
16 KiB
TypeScript
import React from 'react'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import { StatusRule } from '../components/appChrome.js'
|
|
import { DEFAULT_THEME } from '../theme.js'
|
|
|
|
type ReactNodeLike = React.ReactNode
|
|
|
|
const textContent = (node: ReactNodeLike): string => {
|
|
if (node === null || node === undefined || typeof node === 'boolean') {
|
|
return ''
|
|
}
|
|
|
|
if (typeof node === 'string' || typeof node === 'number') {
|
|
return String(node)
|
|
}
|
|
|
|
if (Array.isArray(node)) {
|
|
return node.map(textContent).join('')
|
|
}
|
|
|
|
if (React.isValidElement(node)) {
|
|
return textContent(node.props.children)
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
const findClickableWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
|
|
if (node === null || node === undefined || typeof node === 'boolean') {
|
|
return null
|
|
}
|
|
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) {
|
|
const found = findClickableWithText(child, needle)
|
|
|
|
if (found) {
|
|
return found
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
if (!React.isValidElement(node)) {
|
|
return null
|
|
}
|
|
|
|
if (typeof node.props.onClick === 'function' && textContent(node).includes(needle)) {
|
|
return node
|
|
}
|
|
|
|
return findClickableWithText(node.props.children, needle)
|
|
}
|
|
|
|
// Find the innermost element whose own (direct) text content includes the
|
|
// needle. Used to assert the colour the notice text is rendered with.
|
|
const findElementWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
|
|
if (node === null || node === undefined || typeof node === 'boolean') {
|
|
return null
|
|
}
|
|
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) {
|
|
const found = findElementWithText(child, needle)
|
|
|
|
if (found) {
|
|
return found
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
if (!React.isValidElement(node)) {
|
|
return null
|
|
}
|
|
|
|
// Prefer the deepest matching element so we get the leaf <Text> that
|
|
// actually carries the colour, not an ancestor Box.
|
|
const deeper = findElementWithText(node.props.children, needle)
|
|
|
|
if (deeper) {
|
|
return deeper
|
|
}
|
|
|
|
return textContent(node).includes(needle) ? node : null
|
|
}
|
|
|
|
const baseProps = {
|
|
bgCount: 0,
|
|
busy: false,
|
|
cols: 100,
|
|
cwdLabel: '~/repo',
|
|
liveSessionCount: 0,
|
|
model: 'opus-4.8',
|
|
sessionStartedAt: null,
|
|
status: 'ready',
|
|
statusColor: DEFAULT_THEME.color.ok,
|
|
t: DEFAULT_THEME,
|
|
turnStartedAt: null,
|
|
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
|
voiceLabel: ''
|
|
}
|
|
|
|
describe('StatusRule session title', () => {
|
|
it('pins the named session at the far-right edge instead of the cwd label', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
sessionTitle: 'weekly-digest'
|
|
})
|
|
|
|
const rendered = textContent(element)
|
|
const title = findElementWithText(element, 'weekly-digest')
|
|
|
|
expect(rendered).toContain('weekly-digest')
|
|
expect(rendered).not.toContain('~/repo')
|
|
// Regression for issue #82465: a raw, full-saturation accent-hue
|
|
// background (e.g. #FFBF00 on DARK_SEEDS) paired with statusFg (a
|
|
// near-white tone never designed to sit on it) rendered at roughly a
|
|
// 1.5-2:1 contrast ratio -- unreadable. No background fill at all;
|
|
// the accent color goes on the text instead, matching the theme's
|
|
// own convention that a raw accent hue is never used as a solid
|
|
// fill elsewhere (fills are always softened, e.g. activeRow).
|
|
expect(title?.props.backgroundColor).toBeUndefined()
|
|
expect(title?.props.color).toBe(DEFAULT_THEME.color.accent)
|
|
})
|
|
})
|
|
|
|
describe('StatusRule background-subagent indicator', () => {
|
|
it('renders ⛓ N on a wide terminal when subagents are running', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
usage: { ...baseProps.usage, active_subagents: 3 }
|
|
})
|
|
|
|
expect(textContent(element)).toContain('⛓ 3')
|
|
})
|
|
|
|
it('omits the segment when no subagents are running', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
usage: { ...baseProps.usage, active_subagents: 0 }
|
|
})
|
|
|
|
expect(textContent(element)).not.toContain('⛓')
|
|
})
|
|
|
|
it('omits the segment when the field is absent', () => {
|
|
const element = StatusRule({ ...baseProps })
|
|
|
|
expect(textContent(element)).not.toContain('⛓')
|
|
})
|
|
|
|
it('spells out the auto-resume hint when idle with subagents in flight', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
usage: { ...baseProps.usage, active_subagents: 1 }
|
|
})
|
|
|
|
expect(textContent(element)).toContain('resumes when subagent finishes')
|
|
})
|
|
|
|
it('pluralizes the resume hint for multiple in-flight subagents', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
usage: { ...baseProps.usage, active_subagents: 3 }
|
|
})
|
|
|
|
expect(textContent(element)).toContain('resumes when 3 subagents finish')
|
|
})
|
|
|
|
it('hides the resume hint mid-turn (a busy turn owns the indicator)', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
busy: true,
|
|
turnStartedAt: Date.now(),
|
|
usage: { ...baseProps.usage, active_subagents: 2 }
|
|
})
|
|
|
|
expect(textContent(element)).not.toContain('resumes when')
|
|
})
|
|
|
|
it('omits the resume hint when no subagents are running', () => {
|
|
const element = StatusRule({ ...baseProps })
|
|
|
|
expect(textContent(element)).not.toContain('resumes when')
|
|
})
|
|
|
|
it('drops the subagent segment before the bg segment on a narrow terminal', () => {
|
|
// cols=44 is below the subagents breakpoint (92) but the bg breakpoint
|
|
// (88) too — both gone. Assert the lower-priority subagent indicator is
|
|
// not shown when space is tight even with a live count.
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
cols: 44,
|
|
bgCount: 1,
|
|
usage: { ...baseProps.usage, active_subagents: 2 }
|
|
})
|
|
|
|
expect(textContent(element)).not.toContain('⛓')
|
|
})
|
|
})
|
|
|
|
describe('StatusRule session count click target', () => {
|
|
it('makes the live session count itself clickable', () => {
|
|
const openSwitcher = vi.fn()
|
|
|
|
const element = StatusRule({
|
|
bgCount: 0,
|
|
busy: false,
|
|
cols: 100,
|
|
cwdLabel: '~/repo',
|
|
liveSessionCount: 1,
|
|
model: 'kimi-k2.6',
|
|
onSessionCountClick: openSwitcher,
|
|
sessionStartedAt: null,
|
|
status: 'ready',
|
|
statusColor: DEFAULT_THEME.color.ok,
|
|
t: DEFAULT_THEME,
|
|
turnStartedAt: null,
|
|
usage: { total: 0 },
|
|
voiceLabel: ''
|
|
})
|
|
|
|
const clickableSessionCount = findClickableWithText(element, '1 session')
|
|
|
|
expect(clickableSessionCount).not.toBeNull()
|
|
clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() })
|
|
expect(openSwitcher).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps status + model and drops the low-value tail on a narrow terminal', () => {
|
|
const element = StatusRule({
|
|
bgCount: 0,
|
|
busy: false,
|
|
cols: 44,
|
|
cwdLabel: '~/src/hermes-agent/apps/desktop (bb/tui-statusbar-responsive)',
|
|
liveSessionCount: 3,
|
|
model: 'opus-4.8',
|
|
onSessionCountClick: vi.fn(),
|
|
sessionStartedAt: Date.now() - 60_000,
|
|
status: 'ready',
|
|
statusColor: DEFAULT_THEME.color.ok,
|
|
t: DEFAULT_THEME,
|
|
turnStartedAt: null,
|
|
usage: {
|
|
calls: 0,
|
|
context_max: 200_000,
|
|
context_percent: 25,
|
|
context_used: 50_000,
|
|
input: 0,
|
|
output: 0,
|
|
total: 50_000
|
|
},
|
|
voiceLabel: 'voice off'
|
|
})
|
|
|
|
const rendered = textContent(element)
|
|
|
|
// Must-keep essentials survive intact …
|
|
expect(rendered).toContain('ready')
|
|
expect(rendered).toContain('opus 4.8')
|
|
// … while the low-value tail (session count) is dropped, not truncated.
|
|
expect(rendered).not.toContain('3 sessions')
|
|
})
|
|
})
|
|
|
|
describe('StatusRule credits notice render priority', () => {
|
|
it('replaces the idle status with the notice text and keeps model + context', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' }
|
|
})
|
|
|
|
const rendered = textContent(element)
|
|
|
|
// Notice replaces the status verb slot …
|
|
expect(rendered).toContain('✕ credits exhausted')
|
|
expect(rendered).not.toContain('ready')
|
|
// … but model + context stay visible.
|
|
expect(rendered).toContain('opus 4.8')
|
|
expect(rendered).toContain('50k')
|
|
})
|
|
|
|
it('busy wins: the FaceTicker shows, the notice is hidden mid-turn', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
busy: true,
|
|
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
|
turnStartedAt: Date.now()
|
|
})
|
|
|
|
const rendered = textContent(element)
|
|
|
|
// Notice must NOT render while busy.
|
|
expect(rendered).not.toContain('⚠ 90% used')
|
|
// Model still visible.
|
|
expect(rendered).toContain('opus 4.8')
|
|
})
|
|
|
|
it('colours the notice by level (error → theme error, success → statusGood)', () => {
|
|
const errEl = StatusRule({
|
|
...baseProps,
|
|
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' }
|
|
})
|
|
|
|
const errText = findElementWithText(errEl, '✕ exhausted')
|
|
expect(errText?.props.color).toBe(DEFAULT_THEME.color.error)
|
|
|
|
const okEl = StatusRule({
|
|
...baseProps,
|
|
notice: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }
|
|
})
|
|
|
|
const okText = findElementWithText(okEl, '✓ restored')
|
|
expect(okText?.props.color).toBe(DEFAULT_THEME.color.statusGood)
|
|
})
|
|
|
|
it('does NOT add a glyph — the notice text is rendered verbatim', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }
|
|
})
|
|
|
|
const noticeText = findElementWithText(element, '90% used')
|
|
|
|
// The leaf carries exactly the policy text — no extra prepended glyph.
|
|
expect(noticeText?.props.children).toBe('⚠ 90% used')
|
|
})
|
|
|
|
it('the notice text is the shrinkable element (flexShrink=1 + truncate-end) so a long notice ellipsizes', () => {
|
|
const longText = '⚠ ' + 'x'.repeat(200)
|
|
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
cols: 50,
|
|
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: longText }
|
|
})
|
|
|
|
// The leaf <Text> truncates rather than wrapping/clipping the pinned tail.
|
|
const noticeText = findElementWithText(element, 'xxxxx')
|
|
expect(noticeText?.props.wrap).toBe('truncate-end')
|
|
|
|
// Its container box yields first (flexShrink=1) so model stays visible.
|
|
const findShrinkBoxContaining = (node: ReactNodeLike): React.ReactElement | null => {
|
|
if (!React.isValidElement(node)) {
|
|
if (Array.isArray(node)) {
|
|
for (const c of node) {
|
|
const f = findShrinkBoxContaining(c)
|
|
|
|
if (f) {
|
|
return f
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
if (node.props.flexShrink === 1 && textContent(node).includes('xxxxx') && node.type !== StatusRule) {
|
|
// Prefer the closest shrink box that wraps the notice text.
|
|
const deeper = findShrinkBoxContaining(node.props.children)
|
|
|
|
return deeper ?? node
|
|
}
|
|
|
|
return findShrinkBoxContaining(node.props.children)
|
|
}
|
|
|
|
const shrinkBox = findShrinkBoxContaining(element)
|
|
expect(shrinkBox).not.toBeNull()
|
|
|
|
// Model survives on a narrow terminal because the notice yields.
|
|
expect(textContent(element)).toContain('opus 4.8')
|
|
})
|
|
})
|
|
|
|
describe('StatusRule battery indicator', () => {
|
|
it('renders the battery label with a battery glyph on AC-off', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
battery: { available: true, category: 'good', percent: 82, plugged: false }
|
|
})
|
|
|
|
expect(textContent(element)).toContain('🔋 82%')
|
|
})
|
|
|
|
it('uses a bolt glyph while charging', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
battery: { available: true, category: 'good', percent: 82, plugged: true }
|
|
})
|
|
|
|
expect(textContent(element)).toContain('⚡ 82%')
|
|
})
|
|
|
|
it('colours the read-out by category (critical → theme statusCritical)', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
battery: { available: true, category: 'critical', percent: 7, plugged: false }
|
|
})
|
|
|
|
const leaf = findElementWithText(element, '7%')
|
|
expect(leaf?.props.color).toBe(DEFAULT_THEME.color.statusCritical)
|
|
})
|
|
|
|
it('omits the segment when battery is null', () => {
|
|
const element = StatusRule({ ...baseProps, battery: null })
|
|
|
|
expect(textContent(element)).not.toContain('%🔋')
|
|
expect(textContent(element)).not.toContain('🔋')
|
|
})
|
|
|
|
it('omits the segment when no battery is available (desktop/server)', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
battery: { available: false, category: 'dim', percent: null, plugged: null }
|
|
})
|
|
|
|
expect(textContent(element)).not.toContain('🔋')
|
|
})
|
|
})
|
|
|
|
describe('StatusRule idle-since read-out', () => {
|
|
// The IdleSince component uses hooks, so it can't be invoked outside a
|
|
// renderer — assert on the element tree instead (same reason the duration
|
|
// tests don't check SessionDuration's text).
|
|
const findComponentByName = (node: ReactNodeLike, name: string): React.ReactElement | null => {
|
|
if (node === null || node === undefined || typeof node === 'boolean') {
|
|
return null
|
|
}
|
|
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) {
|
|
const found = findComponentByName(child, name)
|
|
|
|
if (found) {
|
|
return found
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
if (!React.isValidElement(node)) {
|
|
return null
|
|
}
|
|
|
|
if (typeof node.type === 'function' && node.type.name === name) {
|
|
return node
|
|
}
|
|
|
|
return findComponentByName(node.props.children, name)
|
|
}
|
|
|
|
it('shows time since the last final agent response when idle', () => {
|
|
const endedAt = Date.now() - 42_000
|
|
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
lastTurnEndedAt: endedAt,
|
|
sessionStartedAt: Date.now() - 60_000
|
|
})
|
|
|
|
const idle = findComponentByName(element, 'IdleSince')
|
|
|
|
expect(idle).not.toBeNull()
|
|
expect(idle!.props.endedAt).toBe(endedAt)
|
|
})
|
|
|
|
it('is hidden while a turn is busy', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
busy: true,
|
|
lastTurnEndedAt: Date.now() - 42_000,
|
|
turnStartedAt: Date.now()
|
|
})
|
|
|
|
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
|
})
|
|
|
|
it('is hidden before the first turn completes', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
lastTurnEndedAt: null,
|
|
sessionStartedAt: Date.now() - 60_000
|
|
})
|
|
|
|
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('StatusRule perf read-outs (cache hit / latency / tps)', () => {
|
|
const perfUsage = {
|
|
...baseProps.usage,
|
|
avg_latency_s: 3.2,
|
|
avg_tps: 50.4,
|
|
cache_hit_pct: 87,
|
|
calls: 4,
|
|
input: 1000,
|
|
output: 500
|
|
}
|
|
|
|
it('renders all three segments on a wide terminal', () => {
|
|
const element = StatusRule({ ...baseProps, cols: 160, usage: perfUsage })
|
|
const rendered = textContent(element)
|
|
|
|
expect(rendered).toContain('◎ 87%')
|
|
expect(rendered).toContain('◷ 3.2s')
|
|
expect(rendered).toContain('↑ 50 t/s')
|
|
})
|
|
|
|
it('self-hides when the server omits the keys', () => {
|
|
const element = StatusRule({ ...baseProps, cols: 160 })
|
|
const rendered = textContent(element)
|
|
|
|
expect(rendered).not.toContain('◎')
|
|
expect(rendered).not.toContain('◷')
|
|
expect(rendered).not.toContain('t/s')
|
|
})
|
|
|
|
it('honors the display.status_bar.fields visibility filter', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
cols: 160,
|
|
statusBarFields: new Set(['model', 'context_pct', 'cache_hit']),
|
|
usage: perfUsage
|
|
})
|
|
|
|
const rendered = textContent(element)
|
|
|
|
expect(rendered).toContain('◎ 87%')
|
|
expect(rendered).not.toContain('◷')
|
|
expect(rendered).not.toContain('t/s')
|
|
})
|
|
|
|
it('hides the session title badge when the fields filter omits title', () => {
|
|
const element = StatusRule({
|
|
...baseProps,
|
|
cols: 160,
|
|
sessionTitle: 'weekly-digest',
|
|
statusBarFields: new Set(['model', 'context_pct'])
|
|
})
|
|
|
|
expect(textContent(element)).not.toContain('weekly-digest')
|
|
})
|
|
})
|