301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
/**
|
|
* Epic 1.3 — header/status chrome (Variant A dense single status bar).
|
|
* Layers covered:
|
|
* 1. schema: the new SessionInfo wire fields decode (and null/absence is safe)
|
|
* 2. store: applyInfo merges the new usage/chrome fields
|
|
* 3. pure logic: statusSegments width table (priority drop order) + the
|
|
* ctx/cmp threshold levels + compact formatters
|
|
* 4. frames: the bar renders the dense segment layout with separators, drops
|
|
* tail segments when narrow, and the update notice borrows the line
|
|
*/
|
|
import { Option } from 'effect'
|
|
import { describe, expect, test } from 'vitest'
|
|
|
|
import { decodeSessionInfoPatch } from '../boundary/schema/SessionInfo.ts'
|
|
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
|
import {
|
|
chromeMode,
|
|
cmpLevel,
|
|
ctxLevel,
|
|
fmtShortDuration,
|
|
fmtTokens,
|
|
StatusBar,
|
|
statusSegments
|
|
} from '../view/statusBar.tsx'
|
|
import { ThemeProvider } from '../view/theme.tsx'
|
|
import { captureFrame, renderProbe } from './lib/render.ts'
|
|
|
|
// ── 1. schema ────────────────────────────────────────────────────────────
|
|
|
|
describe('SessionInfoPatchSchema — Epic 1.3 wire fields', () => {
|
|
test('decodes the new chrome fields (update/profile/mcp/cost)', () => {
|
|
const decoded = decodeSessionInfoPatch({
|
|
model: 'anthropic/claude-opus-4-8',
|
|
update_behind: 3,
|
|
update_command: 'hermes update',
|
|
profile_name: 'researcher',
|
|
mcp_servers: [{ name: 'railway' }, { name: 'beeper' }],
|
|
usage: { context_percent: 42, context_used: 84_000, cost_usd: 0.41, compressions: 2 }
|
|
})
|
|
expect(Option.isSome(decoded)).toBe(true)
|
|
if (Option.isSome(decoded)) {
|
|
expect(decoded.value.update_behind).toBe(3)
|
|
expect(decoded.value.update_command).toBe('hermes update')
|
|
expect(decoded.value.profile_name).toBe('researcher')
|
|
expect(decoded.value.mcp_servers).toHaveLength(2)
|
|
expect(decoded.value.usage?.cost_usd).toBe(0.41)
|
|
}
|
|
})
|
|
|
|
test('update_behind: null (check not resolved yet) decodes — None-safe', () => {
|
|
const decoded = decodeSessionInfoPatch({ model: 'm', update_behind: null, update_command: '' })
|
|
expect(Option.isSome(decoded)).toBe(true)
|
|
if (Option.isSome(decoded)) expect(decoded.value.update_behind).toBeNull()
|
|
})
|
|
|
|
test('all new fields absent still decodes (every key optional)', () => {
|
|
expect(Option.isSome(decodeSessionInfoPatch({ model: 'm' }))).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ── 2. store applyInfo ───────────────────────────────────────────────────
|
|
|
|
describe('store.applyInfo — Epic 1.3 chrome merge', () => {
|
|
test('merges cost/update/profile/mcp into SessionInfo', () => {
|
|
const store = createSessionStore()
|
|
store.applyInfo({
|
|
model: 'opus',
|
|
update_behind: 4,
|
|
update_command: 'uv tool upgrade hermes',
|
|
profile_name: 'researcher',
|
|
mcp_servers: [{}, {}, {}],
|
|
usage: { cost_usd: 0.4129, context_percent: 42 }
|
|
})
|
|
expect(store.state.info.costUsd).toBeCloseTo(0.4129)
|
|
expect(store.state.info.updateBehind).toBe(4)
|
|
expect(store.state.info.updateCommand).toBe('uv tool upgrade hermes')
|
|
expect(store.state.info.profileName).toBe('researcher')
|
|
expect(store.state.info.mcpServers).toBe(3)
|
|
})
|
|
|
|
test('update_behind: null leaves the prior value alone (partial-patch rule)', () => {
|
|
const store = createSessionStore()
|
|
store.applyInfo({ update_behind: 2 })
|
|
store.applyInfo({ update_behind: null })
|
|
expect(store.state.info.updateBehind).toBe(2)
|
|
})
|
|
|
|
test('a usage patch with cost does not clobber unrelated chrome', () => {
|
|
const store = createSessionStore()
|
|
store.applyInfo({ model: 'opus', profile_name: 'researcher' })
|
|
store.applyInfo({ usage: { cost_usd: 0.1 } })
|
|
expect(store.state.info).toMatchObject({ model: 'opus', profileName: 'researcher', costUsd: 0.1 })
|
|
})
|
|
|
|
test('startedAt is seeded at store creation and never patched off the wire', () => {
|
|
const before = Date.now()
|
|
const store = createSessionStore()
|
|
expect(store.state.info.startedAt).toBeGreaterThanOrEqual(before)
|
|
const seeded = store.state.info.startedAt
|
|
store.applyInfo({ model: 'opus' })
|
|
expect(store.state.info.startedAt).toBe(seeded)
|
|
})
|
|
})
|
|
|
|
// ── 3. pure logic ────────────────────────────────────────────────────────
|
|
|
|
describe('chromeMode — the responsive width ladder (design pass piece 3)', () => {
|
|
test('wide ≥140, medium in between, narrow <72 (where ctx detail collapses)', () => {
|
|
expect(chromeMode(220)).toBe('wide')
|
|
expect(chromeMode(140)).toBe('wide')
|
|
expect(chromeMode(139)).toBe('medium')
|
|
expect(chromeMode(120)).toBe('medium')
|
|
expect(chromeMode(72)).toBe('medium')
|
|
expect(chromeMode(71)).toBe('narrow')
|
|
expect(chromeMode(40)).toBe('narrow')
|
|
expect(chromeMode(0)).toBe('narrow') // degenerate input stays well-formed
|
|
})
|
|
})
|
|
|
|
describe('statusSegments — progressive disclosure table', () => {
|
|
test('full width shows everything', () => {
|
|
expect(statusSegments(220)).toEqual({
|
|
ctxDetail: true,
|
|
duration: true,
|
|
compressions: true,
|
|
cost: true,
|
|
profile: true,
|
|
bg: true,
|
|
mcp: true
|
|
})
|
|
})
|
|
|
|
test('segments drop in reverse priority as width shrinks: mcp → bg → profile → cost → duration/cmp → ctx detail', () => {
|
|
// each row: [width, expected visible flags]
|
|
const table: Array<[number, Partial<ReturnType<typeof statusSegments>>]> = [
|
|
[115, { mcp: false, bg: true }], // mcp drops first
|
|
[107, { mcp: false, bg: false, profile: true }], // then bg
|
|
[99, { profile: false, cost: true }], // then profile
|
|
[91, { cost: false, duration: true, compressions: true }], // then cost
|
|
[79, { duration: false, compressions: false, ctxDetail: true }], // then duration/cmp
|
|
[71, { ctxDetail: false }] // finally the bar/token detail collapses to bare `42%`
|
|
]
|
|
for (const [width, expected] of table) {
|
|
expect(statusSegments(width)).toMatchObject(expected)
|
|
}
|
|
})
|
|
|
|
test('pinned essentials are never gated: statusSegments only governs the tail', () => {
|
|
// even at absurdly narrow widths the table stays well-formed (booleans, no throw)
|
|
const segs = statusSegments(10)
|
|
expect(Object.values(segs).every(v => v === false)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('threshold levels (spec 50/80/95 and cmp 5/10)', () => {
|
|
test('ctxLevel boundaries', () => {
|
|
expect(ctxLevel(0)).toBe('ok')
|
|
expect(ctxLevel(49)).toBe('ok')
|
|
expect(ctxLevel(50)).toBe('warn')
|
|
expect(ctxLevel(79)).toBe('warn')
|
|
expect(ctxLevel(80)).toBe('bad')
|
|
expect(ctxLevel(94)).toBe('bad')
|
|
expect(ctxLevel(95)).toBe('critical')
|
|
expect(ctxLevel(100)).toBe('critical')
|
|
})
|
|
|
|
test('cmpLevel boundaries', () => {
|
|
expect(cmpLevel(0)).toBe('ok')
|
|
expect(cmpLevel(4)).toBe('ok')
|
|
expect(cmpLevel(5)).toBe('warn')
|
|
expect(cmpLevel(9)).toBe('warn')
|
|
expect(cmpLevel(10)).toBe('bad')
|
|
})
|
|
})
|
|
|
|
describe('compact formatters', () => {
|
|
test('fmtTokens', () => {
|
|
expect(fmtTokens(950)).toBe('950')
|
|
expect(fmtTokens(84_321)).toBe('84k')
|
|
expect(fmtTokens(1_000_000)).toBe('1M')
|
|
expect(fmtTokens(1_250_000)).toBe('1.3M')
|
|
})
|
|
|
|
test('fmtShortDuration', () => {
|
|
expect(fmtShortDuration(42)).toBe('42s')
|
|
expect(fmtShortDuration(23 * 60)).toBe('23m')
|
|
expect(fmtShortDuration(65 * 60)).toBe('1h05m')
|
|
})
|
|
})
|
|
|
|
// ── 4. frames ────────────────────────────────────────────────────────────
|
|
|
|
function seededStore(): SessionStore {
|
|
const store = createSessionStore()
|
|
store.apply({ type: 'gateway.ready' })
|
|
store.applyInfo({
|
|
model: 'anthropic/claude-opus-4-8',
|
|
reasoning_effort: 'high',
|
|
cwd: '/tmp/proj',
|
|
branch: 'main',
|
|
profile_name: 'researcher',
|
|
mcp_servers: [{}, {}],
|
|
usage: { context_percent: 42, context_used: 84_000, context_max: 200_000, cost_usd: 0.41, compressions: 2 }
|
|
})
|
|
return store
|
|
}
|
|
|
|
function bar(store: SessionStore) {
|
|
return () => (
|
|
<ThemeProvider theme={() => store.state.theme}>
|
|
<StatusBar store={store} />
|
|
</ThemeProvider>
|
|
)
|
|
}
|
|
|
|
describe('StatusBar frames (responsive chrome)', () => {
|
|
test('WIDE (160) spreads into the two-line layout: vitals line + environment line', async () => {
|
|
const frame = await captureFrame(bar(seededStore()), { width: 160, height: 4 })
|
|
// every segment present…
|
|
expect(frame).toContain('claude-opus-4-8')
|
|
expect(frame).toContain('·high') // effort suffix
|
|
expect(frame).toContain('42%')
|
|
expect(frame).toContain('84k/200k') // wide ctx zone shows used/max tokens
|
|
expect(frame).toContain('░') // the meter is partially filled at 42%
|
|
expect(frame).toContain('$0.41')
|
|
expect(frame).toContain('cmp 2')
|
|
expect(frame).toContain('researcher') // profile badge
|
|
expect(frame).toContain('2 mcp')
|
|
expect(frame).toContain('/tmp/proj (main)')
|
|
expect(frame).toContain('│')
|
|
// …split across TWO lines: vitals (model + cost) up, environment (profile +
|
|
// cwd) below.
|
|
const rows = frame.split('\n')
|
|
const vitals = rows.findIndex(r => r.includes('claude-opus-4-8'))
|
|
const env = rows.findIndex(r => r.includes('researcher'))
|
|
expect(vitals).toBeGreaterThanOrEqual(0)
|
|
expect(env).toBe(vitals + 1)
|
|
expect(rows[vitals]).toContain('$0.41')
|
|
expect(rows[vitals]).toContain('42%')
|
|
expect(rows[env]).toContain('2 mcp')
|
|
expect(rows[env]).toContain('/tmp/proj (main)')
|
|
})
|
|
|
|
test('MEDIUM (120) keeps the dense single line (model and cwd share a row)', async () => {
|
|
const frame = await captureFrame(bar(seededStore()), { width: 120, height: 3 })
|
|
const rows = frame.split('\n')
|
|
const row = rows.find(r => r.includes('claude-opus-4-8')) ?? ''
|
|
expect(row).toContain('42%')
|
|
expect(row).toContain('$0.41')
|
|
expect(row).toContain('/tmp/proj (main)')
|
|
})
|
|
|
|
test('WIDE line 2 carries the agents-running count when subagents are live', async () => {
|
|
const store = seededStore()
|
|
store.apply({ payload: { goal: 'crunch data', subagent_id: 'sa1' }, type: 'subagent.start' })
|
|
const frame = await captureFrame(bar(store), { width: 160, height: 4 })
|
|
expect(frame).toContain('1 agent running')
|
|
const rows = frame.split('\n')
|
|
const env = rows.find(r => r.includes('researcher')) ?? ''
|
|
expect(env).toContain('1 agent running')
|
|
})
|
|
|
|
test('WIDE update notice rides line 2 (no line borrowing — segments stay)', async () => {
|
|
const store = seededStore()
|
|
store.applyInfo({ update_behind: 3, update_command: 'hermes update' })
|
|
const frame = await captureFrame(bar(store), { width: 200, height: 4 })
|
|
expect(frame).toContain('3 commits behind')
|
|
expect(frame).toContain('$0.41') // vitals NOT replaced in wide mode
|
|
expect(frame).toContain('claude-opus-4-8')
|
|
})
|
|
|
|
test('narrow width drops the tail (no mcp/profile/cost) and compacts the context read-out', async () => {
|
|
const frame = await captureFrame(bar(seededStore()), { width: 70, height: 3 })
|
|
expect(frame).toContain('claude-opus-4-8') // pinned
|
|
expect(frame).toContain('42%') // pinned (compact)
|
|
expect(frame).not.toContain('█') // bar detail dropped
|
|
expect(frame).not.toContain('84k')
|
|
expect(frame).not.toContain('$0.41')
|
|
expect(frame).not.toContain('cmp 2')
|
|
expect(frame).not.toContain('researcher')
|
|
expect(frame).not.toContain('mcp')
|
|
})
|
|
|
|
test('update notice borrows the line and Esc dismisses it back to the normal bar', async () => {
|
|
const store = seededStore()
|
|
store.applyInfo({ update_behind: 3, update_command: 'hermes update' })
|
|
const probe = await renderProbe(bar(store), { width: 120, height: 3, kittyKeyboard: true })
|
|
try {
|
|
expect(probe.frame()).toContain('3 commits behind')
|
|
expect(probe.frame()).toContain('hermes update')
|
|
expect(probe.frame()).not.toContain('$0.41') // the notice replaced the segments
|
|
probe.keys.pressEscape()
|
|
await probe.settle()
|
|
const after = await probe.waitForFrame(f => f.includes('$0.41'))
|
|
expect(after).not.toContain('commits behind')
|
|
} finally {
|
|
probe.destroy()
|
|
}
|
|
})
|
|
})
|