From ba3fe7027c0a1c19173a7cac9db04ad8ce901227 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 11 Jun 2026 00:09:31 +0530 Subject: [PATCH] opentui(v6): responsive two-line chrome at wide widths --- ui-opentui/src/test/statusChrome.test.tsx | 71 +++++- ui-opentui/src/view/statusBar.tsx | 296 ++++++++++++++++------ 2 files changed, 291 insertions(+), 76 deletions(-) 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()} + + + + + )