diff --git a/ui-opentui/src/test/statusChrome.test.tsx b/ui-opentui/src/test/statusChrome.test.tsx
index a6192431cff..7b287c40f28 100644
--- a/ui-opentui/src/test/statusChrome.test.tsx
+++ b/ui-opentui/src/test/statusChrome.test.tsx
@@ -13,7 +13,15 @@ import { describe, expect, test } from 'vitest'
import { decodeSessionInfoPatch } from '../boundary/schema/SessionInfo.ts'
import { createSessionStore, type SessionStore } from '../logic/store.ts'
-import { cmpLevel, ctxLevel, fmtShortDuration, fmtTokens, StatusBar, statusSegments } from '../view/statusBar.tsx'
+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'
@@ -96,6 +104,19 @@ describe('store.applyInfo — Epic 1.3 chrome merge', () => {
// ── 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({
@@ -192,13 +213,14 @@ function bar(store: SessionStore) {
)
}
-describe('StatusBar frames (Variant A)', () => {
- test('full width renders every segment with │ separators', async () => {
- const frame = await captureFrame(bar(seededStore()), { width: 160, height: 3 })
+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') // token-count detail
+ 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')
@@ -206,6 +228,45 @@ describe('StatusBar frames (Variant A)', () => {
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 () => {
diff --git a/ui-opentui/src/view/statusBar.tsx b/ui-opentui/src/view/statusBar.tsx
index aa44e88f016..ded90215f18 100644
--- a/ui-opentui/src/view/statusBar.tsx
+++ b/ui-opentui/src/view/statusBar.tsx
@@ -1,20 +1,32 @@
/**
- * StatusBar — Variant A dense single status bar (v6 Epic 1.3; signed-off design).
- * The header stays the minimal brand line; EVERYTHING else lives here, one
- * themed row pinned above the composer:
+ * StatusBar — the session chrome above the composer, RESPONSIVE across a
+ * width ladder (`chromeMode`, design pass piece 3):
+ *
+ * WIDE (≥140 cols) — the chrome breathes: TWO lines of justified zones.
+ * line 1 (session vitals): ● model ·effort ███░░ 42% 84k/200k $0.41 · 23m · cmp 2
+ * line 2 (environment): profile │ 2 mcp │ 1 agent running …/cwd (branch)
+ * A pending update renders in line 2's notice SLOT (before the cwd) instead
+ * of borrowing a line.
+ *
+ * MEDIUM — today's Variant A dense single row (v6 Epic 1.3; signed-off):
*
* ● model ·effort │ ███░░ 42% 84k │ $0.41 · 23m · cmp 2 │ profile │ 2 mcp │ …/cwd (branch)
*
- * Progressive disclosure (Ink's `statusRuleWidths` idiom): the dot+model and
- * the context % are PINNED; the tail segments drop whole as columns shrink, in
- * reverse priority — mcp → bg → profile → cost → duration/cmp → token/bar
- * detail — and the cwd left-truncates into whatever remains. `statusSegments`
- * is the pure width→visibility table (table-tested); nothing truncates
- * mid-segment, so the row NEVER wraps or clips.
+ * NARROW (<72) — pinned essentials only, via the same progressive-disclosure
+ * ladder (Ink's `statusRuleWidths` idiom): the dot+model and the context %
+ * are PINNED; tail segments drop whole as columns shrink, in reverse priority
+ * — mcp → bg → profile → cost → duration/cmp → token/bar detail — and the cwd
+ * left-truncates into whatever remains. `statusSegments` is the pure
+ * width→visibility table (table-tested); nothing truncates mid-segment, so a
+ * row NEVER wraps or clips.
*
- * A pending update (`info.update_behind > 0`) BORROWS the whole line as a
- * transient notice (Variant A decision — no permanent transcript row); it
- * dismisses on Esc or after NOTICE_TTL_MS.
+ * In medium/narrow a pending update (`info.update_behind > 0`) BORROWS the
+ * whole line as a transient notice (Variant A decision — no permanent
+ * transcript row); it dismisses on Esc or after NOTICE_TTL_MS (the Esc/TTL
+ * dismiss also clears the wide notice slot).
+ *
+ * Colors respect the Appendix C roles: the navy `statusBg` fill (the one
+ * correct blue surface), `statusFg` primary text, muted metrics, ok/warn dot.
*
* Parity notes (data that does not reach this TUI yet — reported, not faked):
* - `N bg` (background tasks): the OpenTUI store has no background-task
@@ -30,6 +42,7 @@ import { useKeyboard } from '@opentui/solid'
import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
+import { isTrayAgent } from './agentsTray.tsx'
import { useDimensions } from './dimensions.tsx'
import { elapsedSeconds, useElapsedTick } from './elapsed.ts'
import { useTheme } from './theme.tsx'
@@ -43,6 +56,19 @@ const NOTICE_TTL_MS = 30_000
// ── pure, table-tested width/threshold logic ────────────────────────────
+/** The responsive chrome ladder (design pass piece 3). WIDE spreads the
+ * chrome over TWO justified lines; MEDIUM is the dense single row; NARROW is
+ * the pinned-essentials end of the `statusSegments` drop ladder (where the
+ * ctx read-out has collapsed to a bare `42%`). */
+export type ChromeMode = 'wide' | 'medium' | 'narrow'
+
+export function chromeMode(cols: number): ChromeMode {
+ const w = Math.max(1, Math.floor(cols || 1))
+ if (w >= 140) return 'wide'
+ if (w >= 72) return 'medium'
+ return 'narrow'
+}
+
/** Which tail segments are visible at a given column count. Drop order as the
* terminal narrows (reverse priority, spec Epic 1.3): mcp → bg → profile →
* cost → duration/cmp → ctxDetail (bar+token count collapse to a bare `42%`).
@@ -171,6 +197,7 @@ export function StatusBar(props: { store: SessionStore }) {
info().running ? theme().color.statusWarn : props.store.state.ready ? theme().color.statusGood : theme().color.muted
const segs = createMemo(() => statusSegments(dims().width))
+ const mode = createMemo(() => chromeMode(dims().width))
// ── transient update notice (borrows the whole line; Esc / TTL dismisses) ──
const [dismissed, setDismissed] = createSignal(false)
@@ -256,88 +283,215 @@ export function StatusBar(props: { store: SessionStore }) {
return budget > 4 ? truncLeft(cwdFull(), budget) : ''
})
+ // ── wide-mode extras (the two-line spread) ──────────────────────────────
+ /** Full context read-out with used/max tokens — the wide line-1 center zone. */
+ const wideCtx = createMemo(() => {
+ const p = pct()
+ if (p === undefined) return ''
+ const used = info().contextUsed
+ const max = info().contextMax
+ const tokens = used !== undefined ? ` ${fmtTokens(used)}${max ? `/${fmtTokens(max)}` : ''}` : ''
+ return `${ctxBar(p, CTX_BAR_CELLS)} ${p}%${tokens}`
+ })
+ /** Live delegated-agents count (same predicate as the agents tray). */
+ const agentsText = createMemo(() => {
+ const n = props.store.state.subagents.filter(isTrayAgent).length
+ return n > 0 ? `${n} agent${n === 1 ? '' : 's'} running` : ''
+ })
+ /** Wide line 2's left zone: profile │ N mcp │ agents-running. */
+ const envLeft = createMemo(() => [profileText(), mcpText(), agentsText()].filter(Boolean))
+ /** Wide line 2's right zone trims the cwd around the update-notice slot. */
+ const wideCwd = createMemo(() => {
+ let len = 0
+ for (const seg of envLeft()) len += (len ? SEP.length : 0) + seg.length
+ const notice = noticeText()
+ if (notice) len += (len ? SEP.length : 0) + notice.length
+ const budget = dims().width - 4 - len - 2
+ return budget > 4 ? truncLeft(cwdFull(), budget) : ''
+ })
+
+ /** cost · duration · cmp spans — shared by the medium row and wide line 1. */
+ const Meter = () => (
+ <>
+
+ {costText()}
+
+
+ {DOT_SEP}
+
+
+ {durationText()}
+
+
+ {DOT_SEP}
+
+
+ {cmpText()}
+
+ >
+ )
+
return (
- {truncRight(noticeText(), Math.max(1, dims().width - 4))}
-
+ /* MEDIUM/NARROW — the dense single row (statusSegments drop ladder). */
+
+
+
+ {truncRight(noticeText(), Math.max(1, dims().width - 4))}
+
+
+ }
+ >
+ {/* left: pinned dot+model, then the priority-ordered tail segments */}
+
+
+ {dot()}
+
+ {` ${model()}`}
+ {effort()}
+
+
+ {SEP}
+ {/* ctxText() truthy guarantees pct() is defined; `?? 0` only satisfies the type. */}
+ {ctxText()}}
+ >
+ {ctxBar(pct() ?? 0, CTX_BAR_CELLS)}
+ {` ${pct()}%`}
+
+ {` ${fmtTokens(info().contextUsed ?? 0)}`}
+
+
+
+
+ {SEP}
+
+
+
+ {SEP}
+ {/* statusFg, not accent — persistent chrome spends no warm ink
+ (design pass); the navy fill is the bar's one blue surface. */}
+ {profileText()}
+
+ {/* `N bg` would slot here (segs().bg) — no store data feeds it yet (see header). */}
+
+ {SEP}
+ {mcpText()}
+
+
+
+
+ {/* spacer pushes the cwd to the right edge */}
+
+
+ {/* right: cwd (branch), pre-truncated so the row never wraps */}
+
+
+
+ {rightText()}
+
+
+
+
+
}
>
- {/* left: pinned dot+model, then the priority-ordered tail segments */}
+ {/* WIDE line 1 — session vitals in justified zones:
+ model·effort │ ctx bar + tokens │ cost·duration·cmp */}
-
- {dot()}
-
- {` ${model()}`}
- {effort()}
-
-
- {SEP}
- {/* ctxText() truthy guarantees pct() is defined; `?? 0` only satisfies the type. */}
- {ctxText()}}>
+
+
+ {dot()}
+
+ {` ${model()}`}
+ {effort()}
+
+
+
+
+
+
+
{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}
{` ${pct()}%`}
- {` ${fmtTokens(info().contextUsed ?? 0)}`}
+
+ {` ${fmtTokens(info().contextUsed ?? 0)}${
+ info().contextMax ? `/${fmtTokens(info().contextMax ?? 0)}` : ''
+ }`}
+
-
-
-
- {SEP}
-
- {costText()}
-
-
- {DOT_SEP}
-
-
- {durationText()}
-
-
- {DOT_SEP}
-
-
- {cmpText()}
-
-
-
- {SEP}
- {/* statusFg, not accent — persistent chrome spends no warm ink
- (design pass); the navy fill is the bar's one blue surface. */}
- {profileText()}
-
- {/* `N bg` would slot here (segs().bg) — no store data feeds it yet (see header). */}
-
- {SEP}
- {mcpText()}
-
-
+
+
+
+
+
+
+
+
+
+
+
- {/* spacer pushes the cwd to the right edge */}
-
-
- {/* right: cwd (branch), pre-truncated so the row never wraps */}
-
-
+ {/* WIDE line 2 — environment:
+ profile │ N mcp │ agents-running … update-notice slot │ cwd (branch) */}
+
+
- {rightText()}
+
+ {profileText()}
+
+
+ {SEP}
+
+
+ {mcpText()}
+
+
+ {SEP}
+
+
+ {agentsText()}
+
-
+
+
+
+
+ {/* the update notice rides line 2's slot in wide mode — no line
+ borrowing; Esc/TTL dismisses it exactly as in medium. */}
+
+ {noticeText()}
+
+
+ {SEP}
+
+
+ {wideCwd()}
+
+
+
+
+
)