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
100 changes: 100 additions & 0 deletions ui-tui/src/__tests__/appChrome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { renderSync } from '@hermes/ink'
import React from 'react'
import { PassThrough } from 'stream'
import { describe, expect, it } from 'vitest'

import { effortLabel, modelLabel, StatusRule } from '../components/appChrome.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'

const renderStatusRule = (statusBarSegments: readonly string[]) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''

Object.assign(stdout, { columns: 120, isTTY: false, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})

const instance = renderSync(
React.createElement(StatusRule, {
bgCount: 0,
busy: false,
cols: 120,
cwdLabel: '~/workspace',
model: 'gpt-5.5',
modelFast: false,
modelReasoningEffort: 'medium',
sessionStartedAt: null,
showCost: false,
status: 'ready',
statusBarSegments,
statusColor: DEFAULT_THEME.color.statusFg,
t: DEFAULT_THEME,
turnStartedAt: null,
usage: {
context_max: 272_000,
context_percent: 0,
context_used: 0,
total: 0
},
voiceLabel: 'voice off'
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)

instance.unmount()
instance.cleanup()

return stripAnsi(output)
}

describe('status bar model label', () => {
it('keeps medium reasoning visible as @med', () => {
expect(modelLabel('gpt-5.5', 'medium')).toBe('gpt 5.5@med')
})

it('shortens minimal reasoning and keeps explicit efforts adjacent to the model', () => {
expect(modelLabel('openai/gpt-5.5', 'minimal')).toBe('gpt 5.5@min')
expect(modelLabel('openai/gpt-5.5', 'xhigh')).toBe('gpt 5.5@xhigh')
})

it('omits only neutral/default effort labels', () => {
expect(effortLabel('default')).toBe('')
expect(effortLabel('normal')).toBe('')
expect(modelLabel('gpt-5.5')).toBe('gpt 5.5')
})

it('keeps fast as a separate suffix after the effort', () => {
expect(modelLabel('gpt-5.5', 'medium', true)).toBe('gpt 5.5@med fast')
})
})

describe('StatusRule segment rendering', () => {
it('can hide only the context meter while keeping tokens and percent', () => {
const output = renderStatusRule(['indicator', 'model', 'context_tokens', 'context_percent', 'voice'])

expect(output).toContain('ready')
expect(output).toContain('gpt 5.5@med')
expect(output).toContain('0/272k')
expect(output).toContain('0%')
expect(output).toContain('voice off')
expect(output).not.toContain('[░░░░░░░░░░]')
})

it('renders the context meter only when context_bar is enabled', () => {
const output = renderStatusRule(['context_tokens', 'context_bar', 'context_percent'])

expect(output).toContain('0/272k')
expect(output).toContain('[░░░░░░░░░░] 0%')
})
})
22 changes: 22 additions & 0 deletions ui-tui/src/__tests__/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,28 @@ describe('fromSkin', () => {
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO')
})

it('maps status bar color keys from skins', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin(
{
status_bar_bg: '#010203',
status_bar_text: '#0ADAF5',
status_bar_good: '#112233',
status_bar_warn: '#445566',
status_bar_bad: '#778899',
status_bar_critical: '#AABBCC'
},
{}
)

expect(color.statusBg).toBe('#010203')
expect(color.statusFg).toBe('#0ADAF5')
expect(color.statusGood).toBe('#112233')
expect(color.statusWarn).toBe('#445566')
expect(color.statusBad).toBe('#778899')
expect(color.statusCritical).toBe('#AABBCC')
})

it('maps ui_ color keys + cascades to status', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ ui_ok: '#008000' }, {})
Expand Down
48 changes: 47 additions & 1 deletion ui-tui/src/__tests__/useConfigSync.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $uiState, resetUiState } from '../app/uiStore.js'
import { DEFAULT_STATUS_BAR_SEGMENTS } from '../app/interfaces.js'
import {
applyDisplay,
hydrateFullConfig,
normalizeBusyInputMode,
normalizeIndicatorStyle,
normalizeMouseTracking,
normalizeStatusBar
normalizeStatusBar,
normalizeStatusBarSegments
} from '../app/useConfigSync.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'

Expand Down Expand Up @@ -152,6 +154,50 @@ describe('applyDisplay', () => {
applyDisplay({ config: { display: { tui_statusbar: 'top' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
})

it('threads display.tui_statusbar_segments into $uiState', () => {
const setBell = vi.fn()

applyDisplay(
{
config: {
display: {
tui_statusbar_segments: ['indicator', 'model', 'unknown', 'cost', 'model']
}
}
},
setBell
)

expect($uiState.get().statusBarSegments).toEqual(['indicator', 'model', 'cost'])
})
})

describe('normalizeStatusBarSegments', () => {
it('defaults missing or malformed values to the built-in segment order', () => {
expect(normalizeStatusBarSegments(undefined)).toEqual(DEFAULT_STATUS_BAR_SEGMENTS)
expect(normalizeStatusBarSegments(null)).toEqual(DEFAULT_STATUS_BAR_SEGMENTS)
expect(normalizeStatusBarSegments('model')).toEqual(DEFAULT_STATUS_BAR_SEGMENTS)
})

it('trims, lowercases, filters unknown values, expands legacy context, and de-duplicates', () => {
expect(normalizeStatusBarSegments([' Model ', 'context', 'bogus', 'MODEL', 7, 'cost'])).toEqual([
'model',
'context_tokens',
'context_bar',
'context_percent',
'cost'
])
})

it('allows hiding only the context bar while retaining context tokens and percent', () => {
expect(normalizeStatusBarSegments(['context_tokens', 'context_percent'])).toEqual(['context_tokens', 'context_percent'])
})

it('allows an empty segment list to hide the configurable left side', () => {
expect(normalizeStatusBarSegments([])).toEqual([])
expect(normalizeStatusBarSegments(['bogus'])).toEqual([])
})
})

describe('normalizeStatusBar', () => {
Expand Down
19 changes: 19 additions & 0 deletions ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ export interface StateSetter<T> {

export type StatusBarMode = 'bottom' | 'off' | 'top'

export const STATUS_BAR_SEGMENTS = [
'indicator',
'model',
'context_tokens',
'context_bar',
'context_percent',
'account_usage',
'session_duration',
'compressions',
'subagents',
'voice',
'bg_tasks',
'rate_limit',
'cost'
] as const
export type StatusBarSegment = (typeof STATUS_BAR_SEGMENTS)[number]
export const DEFAULT_STATUS_BAR_SEGMENTS: readonly StatusBarSegment[] = STATUS_BAR_SEGMENTS

export type BusyInputMode = 'interrupt' | 'queue' | 'steer'

// Single source of truth for indicator style names. Union type is
Expand Down Expand Up @@ -112,6 +130,7 @@ export interface UiState {
sid: null | string
status: string
statusBar: StatusBarMode
statusBarSegments: readonly StatusBarSegment[]
streaming: boolean
theme: Theme
usage: Usage
Expand Down
7 changes: 6 additions & 1 deletion ui-tui/src/app/uiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
import { DEFAULT_THEME } from '../theme.js'

import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
import {
DEFAULT_INDICATOR_STYLE,
DEFAULT_STATUS_BAR_SEGMENTS,
type UiState
} from './interfaces.js'

const buildUiState = (): UiState => ({
bgTasks: new Set(),
Expand All @@ -23,6 +27,7 @@ const buildUiState = (): UiState => ({
sid: null,
status: 'summoning hermes…',
statusBar: 'top',
statusBarSegments: DEFAULT_STATUS_BAR_SEGMENTS,
streaming: true,
theme: DEFAULT_THEME,
usage: ZERO
Expand Down
37 changes: 36 additions & 1 deletion ui-tui/src/app/useConfigSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import { asRpcResult } from '../lib/rpc.js'
import {
type BusyInputMode,
DEFAULT_INDICATOR_STYLE,
DEFAULT_STATUS_BAR_SEGMENTS,
INDICATOR_STYLES,
type IndicatorStyle,
type StatusBarMode
STATUS_BAR_SEGMENTS,
type StatusBarMode,
type StatusBarSegment
} from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
Expand Down Expand Up @@ -56,6 +59,10 @@ export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
}

const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
const STATUS_BAR_SEGMENT_SET: ReadonlySet<StatusBarSegment> = new Set(STATUS_BAR_SEGMENTS)
const LEGACY_STATUS_BAR_SEGMENTS: Readonly<Record<string, readonly StatusBarSegment[]>> = {
context: ['context_tokens', 'context_bar', 'context_percent']
}

export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
if (typeof raw !== 'string') {
Expand All @@ -67,6 +74,33 @@ export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
}

export const normalizeStatusBarSegments = (raw: unknown): readonly StatusBarSegment[] => {
if (!Array.isArray(raw)) {
return DEFAULT_STATUS_BAR_SEGMENTS
}

const out: StatusBarSegment[] = []
const seen = new Set<StatusBarSegment>()

for (const item of raw) {
if (typeof item !== 'string') {
continue
}

const rawSegment = item.trim().toLowerCase()
const segments = LEGACY_STATUS_BAR_SEGMENTS[rawSegment] ?? [rawSegment as StatusBarSegment]

for (const segment of segments) {
if (STATUS_BAR_SEGMENT_SET.has(segment) && !seen.has(segment)) {
seen.add(segment)
out.push(segment)
}
}
}

return out
}

const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)

Expand Down Expand Up @@ -147,6 +181,7 @@ export const applyDisplay = (
showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning,
statusBar: normalizeStatusBar(d.tui_statusbar),
statusBarSegments: normalizeStatusBarSegments(d.tui_statusbar_segments),
streaming: d.streaming !== false
})
}
Expand Down
11 changes: 7 additions & 4 deletions ui-tui/src/app/useMainApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,20 @@ const capHistory = (items: Msg[]): Msg[] => {
return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
}

const statusColorOf = (status: string, t: { error: string; muted: string; ok: string; warn: string }) => {
const statusColorOf = (
status: string,
t: { error: string; muted: string; statusCritical: string; statusGood: string; statusWarn: string }
) => {
if (status === 'ready') {
return t.ok
return t.statusGood
}

if (status.startsWith('error')) {
return t.error
return t.statusCritical
}

if (status === 'interrupted') {
return t.warn
return t.statusWarn
}

return t.muted
Expand Down
Loading