opentui(v6): status chrome v3 — one left-aligned labeled line; copy chip off the scrollbar edge

This commit is contained in:
alt-glitch 2026-06-11 01:42:59 +05:30
parent 31e0adc681
commit 7e01a96e53
5 changed files with 270 additions and 400 deletions

View file

@ -1,11 +1,12 @@
/**
* Per-block copy affordance (design pass piece 2). Layers:
* Per-block copy affordance (design pass piece 2; chrome-v3 geometry). Layers:
* 1. pure: copyBlock writes the SOURCE through the injectable writer and
* flashes "Copied" via the notify seam (the store's hint line).
* 2. frames: a quiet `` chip trails settled assistant text blocks and user
* prompts (never system rows, never a still-streaming block); clicking it
* through the real mouse path copies that block's source and the hint
* line shows "Copied".
* 2. frames: a quiet `⧉ copy` run sits on its own line at the BOTTOM-LEFT of
* settled assistant text blocks and user prompts (never system rows,
* never a still-streaming block) off the scrollbar's right-edge column;
* clicking it through the real mouse path copies that block's source and
* the hint line shows "Copied".
*/
import { afterEach, describe, expect, test } from 'vitest'
@ -34,13 +35,18 @@ async function mountApp(store: Store, width = 80, height = 30): Promise<RenderPr
)
}
/** Click the `⧉` chip on the frame row that contains `anchor`. */
/** Click the `⧉ copy` chip on the line BELOW the frame row containing `anchor`. */
async function clickChipNear(probe: RenderProbe, anchor: string): Promise<void> {
const frame = await probe.waitForFrame(f => f.includes(anchor) && f.includes('⧉'))
const frame = await probe.waitForFrame(f => f.includes(anchor) && f.includes('⧉ copy'))
const rows = frame.split('\n')
const y = rows.findIndex(line => line.includes(anchor) && line.includes('⧉'))
const anchorY = rows.findIndex(line => line.includes(anchor))
expect(anchorY).toBeGreaterThanOrEqual(0)
const y = rows.findIndex((line, i) => i > anchorY && line.includes('⧉ copy'))
expect(y).toBeGreaterThanOrEqual(0)
const x = (rows[y] ?? '').indexOf('⧉')
// bottom-LEFT chrome: the chip hugs the content gutter (shell padding +
// glyph gutter ≈ col 3), never the scrollbar's right-edge column.
expect(x).toBeLessThan(10)
await probe.click(x, y)
}
@ -104,11 +110,13 @@ describe('⧉ chip frames — quiet chrome, source-true copy', () => {
store.apply({ type: 'message.complete' })
const probe = await mountApp(store)
try {
const frame = await probe.waitForFrame(f => f.includes('⧉'))
expect(frame).toContain('⧉')
const frame = await probe.waitForFrame(f => f.includes('⧉ copy'))
expect(frame).toContain('⧉ copy')
const rows = frame.split('\n')
const y = rows.findIndex(line => line.includes('⧉'))
const y = rows.findIndex(line => line.includes('⧉ copy'))
const x = (rows[y] ?? '').indexOf('⧉')
// bottom-left chrome: never in the scrollbar's right-edge column.
expect(x).toBeLessThan(10)
await probe.click(x, y)
// SOURCE, not the concealed rendered text: the ** markers survive.
expect(writes).toEqual(['the **bold** answer'])

View file

@ -116,22 +116,23 @@ describe('/compact — transcript spacing (frame line-count)', () => {
const b = rows.findIndex(r => r.includes('beta-line'))
expect(a).toBeGreaterThanOrEqual(0)
// user turns are set off by MORE space than the part gap (design pass:
// turn boundary > part gap): top 2 + bottom 1 around each prompt.
expect(b - a).toBe(4)
// turn boundary > part gap): top 2 + bottom 1 around each prompt, plus
// the prompt's own `⧉ copy` footer line (chrome v3 chip placement).
expect(b - a).toBe(5)
store.setCompact(true)
await probe.settle()
const dense = probe.frame().split('\n')
const a2 = dense.findIndex(r => r.includes('alpha-line'))
const b2 = dense.findIndex(r => r.includes('beta-line'))
expect(b2 - a2).toBe(1) // adjacent rows — densified
expect(b2 - a2).toBe(1) // adjacent rows — densified (copy-chip chrome shed too)
store.setCompact(false)
await probe.settle()
const again = probe.frame().split('\n')
const a3 = again.findIndex(r => r.includes('alpha-line'))
const b3 = again.findIndex(r => r.includes('beta-line'))
expect(b3 - a3).toBe(4)
expect(b3 - a3).toBe(5)
} finally {
probe.destroy()
}

View file

@ -1,12 +1,14 @@
/**
* Epic 1.3 header/status chrome (Variant A dense single status bar).
* Status chrome v3 ONE left-aligned labeled line at every width.
* 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
* 1. schema: the SessionInfo wire fields decode (and null/absence is safe)
* 2. store: applyInfo merges the usage/chrome fields
* 3. pure logic: statusSegments width table (priority drop order), the
* ctxBarCells gauge ladder, ctx/cmp threshold levels + compact formatters
* 4. frames: the bar renders ONE left-flowing labeled row (`ctx:`/`cost:`/
* `up:`/`cmp:`/`mcp:`), drops tail segments whole as the terminal
* narrows (never wraps to a second line), and the update notice borrows
* the line.
*/
import { Option } from 'effect'
import { describe, expect, test } from 'vitest'
@ -14,8 +16,8 @@ import { describe, expect, test } from 'vitest'
import { decodeSessionInfoPatch } from '../boundary/schema/SessionInfo.ts'
import { createSessionStore, type SessionStore } from '../logic/store.ts'
import {
chromeMode,
cmpLevel,
ctxBarCells,
ctxLevel,
fmtShortDuration,
fmtTokens,
@ -27,8 +29,8 @@ 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)', () => {
describe('SessionInfoPatchSchema — chrome wire fields', () => {
test('decodes the chrome fields (update/profile/mcp/cost)', () => {
const decoded = decodeSessionInfoPatch({
model: 'anthropic/claude-opus-4-8',
update_behind: 3,
@ -53,14 +55,14 @@ describe('SessionInfoPatchSchema — Epic 1.3 wire fields', () => {
if (Option.isSome(decoded)) expect(decoded.value.update_behind).toBeNull()
})
test('all new fields absent still decodes (every key optional)', () => {
test('all chrome 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', () => {
describe('store.applyInfo — chrome merge', () => {
test('merges cost/update/profile/mcp into SessionInfo', () => {
const store = createSessionStore()
store.applyInfo({
@ -104,41 +106,29 @@ 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', () => {
describe('statusSegments — progressive disclosure table (chrome v3 order)', () => {
test('full width shows everything', () => {
expect(statusSegments(220)).toEqual({
ctxDetail: true,
duration: true,
compressions: true,
cost: true,
up: true,
compressions: true,
profile: true,
bg: true,
mcp: true
})
})
test('segments drop in reverse priority as width shrinks: mcp → bg → profile → cost → duration/cmp → ctx detail', () => {
test('segments drop whole in reverse priority as width shrinks: mcp → bg → profile → cmp → up → cost → 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%`
[125, { mcp: false, bg: true }], // mcp drops first
[117, { mcp: false, bg: false, profile: true }], // then bg
[107, { profile: false, compressions: true }], // then profile
[93, { compressions: false, up: true }], // then cmp
[87, { up: false, cost: true }], // then uptime
[79, { cost: false, ctxDetail: true }], // then cost
[71, { ctxDetail: false }] // finally the bar/token detail collapses to `ctx: 42%`
]
for (const [width, expected] of table) {
expect(statusSegments(width)).toMatchObject(expected)
@ -152,6 +142,19 @@ describe('statusSegments — progressive disclosure table', () => {
})
})
describe('ctxBarCells — the gauge breathes (1014 cells, vs the old 5)', () => {
test('14 cells wide, 12 at normal widths, 10 when tight', () => {
expect(ctxBarCells(220)).toBe(14)
expect(ctxBarCells(160)).toBe(14)
expect(ctxBarCells(159)).toBe(12)
expect(ctxBarCells(120)).toBe(12)
expect(ctxBarCells(100)).toBe(12)
expect(ctxBarCells(99)).toBe(10)
expect(ctxBarCells(80)).toBe(10)
expect(ctxBarCells(0)).toBe(10) // degenerate input stays well-formed
})
})
describe('threshold levels (spec 50/80/95 and cmp 5/10)', () => {
test('ctxLevel boundaries', () => {
expect(ctxLevel(0)).toBe('ok')
@ -213,72 +216,76 @@ function bar(store: SessionStore) {
)
}
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')
describe('StatusBar frames (one left-aligned labeled line)', () => {
test('WIDE (220) renders every labeled segment in order on ONE line, cwd last', async () => {
const frame = await captureFrame(bar(seededStore()), { width: 220, height: 4 })
const rows = frame.split('\n').filter(r => r.trim())
const row = rows.find(r => r.includes('claude-opus-4-8')) ?? ''
// ONE line carries everything…
expect(row).toContain('·high') // effort suffix
expect(row).toContain('ctx: ') // labeled gauge
expect(row).toContain('42%')
expect(row).toContain('$0.41')
expect(row).toContain('· 84k')
expect(row).toContain('█'.repeat(6)) // 42% of a 14-cell bar = 6 filled
expect(row).toContain('░')
expect(row).toContain('cost: $0.41')
expect(row).toContain('up: ')
expect(row).toContain('cmp: 2')
expect(row).toContain('researcher') // profile badge, plain
expect(row).toContain('mcp: 2')
expect(row).toContain('/tmp/proj (main)')
expect(row).toContain('│')
// …in the v3 order: model → ctx → cost → up → cmp → profile → mcp → cwd
const order = ['claude-opus-4-8', 'ctx: ', 'cost: ', 'up: ', 'cmp: ', 'researcher', 'mcp: ', '/tmp/proj']
const positions = order.map(s => row.indexOf(s))
expect(positions.every(p => p >= 0)).toBe(true)
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
// …and no other row carries chrome: the bar never restacks to two lines.
expect(rows.filter(r => r.includes('│')).length).toBe(1)
})
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('left-aligned: no right-pinned cwd — the row ends where the segments end', async () => {
const frame = await captureFrame(bar(seededStore()), { width: 220, height: 4 })
const row = frame.split('\n').find(r => r.includes('claude-opus-4-8')) ?? ''
// the cwd flows immediately after the mcp segment, not at the right edge:
// a 220-col row with a left-flowing tail has a long blank run after it.
expect(row.trimEnd().length).toBeLessThan(160)
})
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('MEDIUM (120) keeps one labeled line; the cwd tail-truncates into the leftover budget', async () => {
const frame = await captureFrame(bar(seededStore()), { width: 120, height: 3 })
const rows = frame.split('\n').filter(r => r.trim())
const row = rows.find(r => r.includes('claude-opus-4-8')) ?? ''
expect(row).toContain('ctx: ')
expect(row).toContain('█'.repeat(5)) // 42% of a 12-cell bar = 5 filled
expect(row).toContain('cost: $0.41')
expect(row).toContain('cmp: 2')
expect(row).toContain('researcher')
expect(row).not.toContain('mcp:') // mcp dropped below 126 cols
expect(row).toContain('(main)') // cwd survives (tail-truncated)
expect(rows.filter(r => r.includes('│')).length).toBe(1) // still ONE line
})
test('narrow width drops the tail (no mcp/profile/cost) and compacts the context read-out', async () => {
test('narrow (78) drops the tail whole (no cost/up/cmp/profile/mcp) and compacts the gauge', async () => {
const frame = await captureFrame(bar(seededStore()), { width: 78, height: 3 })
expect(frame).toContain('claude-opus-4-8') // pinned
expect(frame).toContain('ctx: ') // pinned, still labeled
expect(frame).toContain('42%')
expect(frame).toContain('█') // ctxDetail holds at ≥72
expect(frame).not.toContain('cost:')
expect(frame).not.toContain('up:')
expect(frame).not.toContain('cmp:')
expect(frame).not.toContain('researcher')
expect(frame).not.toContain('mcp:')
})
test('very narrow (70) collapses the gauge to a bare labeled percent', 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).toContain('ctx: 42%') // pinned (compact, still labeled)
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 () => {

View file

@ -14,12 +14,18 @@
* interstitial narration text demotes to muted and only the FINAL text block
* keeps the full-bright answer color (see `lastTextId`).
*
* Per-block copy (piece 2): every settled assistant text block and every user
* prompt carries a quiet `` chip at its top-right muted chrome
* (selectable=false) that disappears into the frame until wanted. Click
* copies that block's SOURCE text (the markdown source in the store, same as
* `/copy` not the concealed rendered text) via logic/blockCopy and flashes
* "Copied" on the existing hint line.
* Per-block copy (piece 2, relocated in the chrome-v3 pass): every settled
* assistant text block and every user prompt carries a quiet `⧉ copy` run on
* its own line at the block's BOTTOM-LEFT muted chrome (selectable=false)
* that disappears into the frame until wanted. Bottom-left because the old
* top-right chip sat in the scrollbar's column (the user's complaint); the
* left gutter belongs to the role glyph and is only 1 cell of click target,
* while a trailing `⧉ copy` line never overlaps the scrollbar, keeps a 6-cell
* click target, and reads as a quiet footer. Click copies that block's
* SOURCE text (the markdown source in the store, same as `/copy` not the
* concealed rendered text) via logic/blockCopy and flashes "Copied" on the
* existing hint line. /compact hides the chip line entirely density mode
* sheds chrome rows first (`/copy` still covers whole-response copy there).
*
* Stable `id` per part as the <For> key so a new tool part below a streaming text
* part doesn't remount it.
@ -85,16 +91,19 @@ export function lastTextId(parts: readonly Part[] | undefined): string | undefin
}
/**
* The quiet per-block copy chip muted `` chrome at a block's top-right.
* Click copies the block's SOURCE (markdown source / prompt text) and flashes
* "Copied". selectable=false: it must never ride along in a drag-selection.
* The quiet per-block copy chip a muted `⧉ copy` run on its own line at the
* block's BOTTOM-LEFT (never the right edge: that column belongs to the
* scrollbar). `alignSelf: flex-start` shrinks the click target to the run
* itself (6 cells) instead of the whole row. Click copies the block's SOURCE
* (markdown source / prompt text) and flashes "Copied". selectable=false: it
* must never ride along in a drag-selection.
*/
function CopyChip(props: { source: () => string }) {
const theme = useTheme()
return (
<box style={{ flexShrink: 0, marginLeft: 1 }} onMouseDown={() => copyBlock(props.source())}>
<box style={{ flexShrink: 0, alignSelf: 'flex-start' }} onMouseDown={() => copyBlock(props.source())}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}></span>
<span style={{ fg: theme().color.muted }}> copy</span>
</text>
</box>
)
@ -147,15 +156,14 @@ export function MessageLine(props: { message: Message; latest?: boolean }) {
// themed selection: a solid muted/accent bar that preserves the
// text fg (no selectionFg → the original color shows through, so a
// highlight over content reads as a clean bar, not SGR-inverse).
// A quiet ⧉ chip trails the block (user prompts + settled
// assistant rows; system notes are chrome, nothing to copy).
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexGrow: 1, minWidth: 0 }}>
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
</box>
<Show when={m().role !== 'system' && m().text.trim()}>
// A quiet ⧉ copy run sits bottom-left under the block (user
// prompts + settled assistant rows; system notes are chrome,
// nothing to copy) — never the right edge: scrollbar column.
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
<Show when={m().role !== 'system' && m().text.trim() && !display().compact}>
<CopyChip source={() => m().text} />
</Show>
</box>
@ -190,17 +198,16 @@ export function MessageLine(props: { message: Message; latest?: boolean }) {
tables natively (#3). Leading/trailing blanks stripped so the
column `gap` is the sole inter-part spacing (item 5).
Interstitial narration demotes to muted once settled; a
quiet copy chip sits at the settled block's top-right. */}
quiet copy run sits bottom-left under the settled block
(off the scrollbar's right-edge column). */}
{t => (
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Markdown
text={t().text.replace(/^\n+|\n+$/g, '')}
streaming={m().streaming ?? false}
fg={textFg(t().id)}
/>
</box>
<Show when={!m().streaming}>
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<Markdown
text={t().text.replace(/^\n+|\n+$/g, '')}
streaming={m().streaming ?? false}
fg={textFg(t().id)}
/>
<Show when={!m().streaming && !display().compact}>
<CopyChip source={() => t().text} />
</Show>
</box>

View file

@ -1,35 +1,35 @@
/**
* StatusBar the session chrome above the composer, RESPONSIVE across a
* width ladder (`chromeMode`, design pass piece 3):
* StatusBar the session chrome above the composer. ONE left-aligned line at
* EVERY width (status chrome v3 user feedback: "everything left-aligned,
* all on one line, no random scatter"):
*
* 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.
* model ·effort ctx: 42% · 84k cost: $0.41 up: 23m cmp: 2 profile mcp: 2 /cwd (branch)
*
* MEDIUM today's Variant A dense single row (v6 Epic 1.3; signed-off):
* Design rules (this pass):
* - Every segment is LABELED and terse (`ctx:`, `cost:`, `up:`, `cmp:`,
* `mcp:`; the profile name reads as itself) self-describing, lowercase,
* no decoration.
* - No right-pinning, no flexGrow spacer, no two-line wide mode: segments
* flow left with generous `` separators; the cwd comes LAST and
* tail-truncates into whatever width remains (dropped entirely below a
* useful budget never squeezed to noise).
* - The context bar is the chrome's most important gauge, so it BREATHES:
* 1014 cells by terminal width (`ctxBarCells`), vs the old 5.
* - Responsive = drop, don't restack: as the terminal narrows, tail segments
* drop WHOLE in reverse priority (mcp bg profile cmp up cost
* ctx detail collapsing to a bare `ctx: 42%`) via the pure, table-tested
* `statusSegments` ladder. The health dot, model and ctx % are pinned.
* Nothing truncates mid-segment, so the row NEVER wraps or restacks.
*
* model ·effort 42% 84k $0.41 · 23m · cmp 2 profile 2 mcp /cwd (branch)
*
* 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
* widthvisibility table (table-tested); nothing truncates mid-segment, so a
* row NEVER wraps or clips.
*
* 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).
* A pending update (`info.update_behind > 0`) BORROWS the whole line as a
* transient notice; it dismisses on Esc or after NOTICE_TTL_MS.
*
* Colors respect the Appendix C roles: the navy `statusBg` fill (the one
* correct blue surface), `statusFg` primary text, muted metrics, ok/warn dot.
* correct blue surface), `statusFg` for the model/profile/percent, muted
* metrics + labels, ok/warn dot, level-tinted ctx bar and cmp count.
*
* Parity notes (data that does not reach this TUI yet reported, not faked):
* - `N bg` (background tasks): the OpenTUI store has no background-task
* - `bg: N` (background tasks): the OpenTUI store has no background-task
* tracking (Ink counts `prompt.background` task_ids + `background.complete`
* locally); the segment slot exists in `statusSegments` but renders nothing.
* - `display.show_cost`: Ink reads it from its `config.get` polling loop,
@ -42,43 +42,35 @@ 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'
const HOME = process.env.HOME ?? ''
const CTX_BAR_CELLS = 5
const SEP = ' │ '
const DOT_SEP = ' · '
/** Horizontal cells the bar's row loses to chrome: the app shell's padding (2)
* + this box's own padding (2). */
const ROW_PADDING = 4
/** Minimum useful cwd budget — below this the cwd drops instead of squeezing. */
const CWD_MIN = 10
/** How long the transient update notice may borrow the bar line. */
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%`).
* Dot+model and the context % are pinned and never gated here. */
* terminal narrows (reverse priority): mcp bg profile cmp up
* cost ctxDetail (the bar+token read-out collapses to a bare `ctx: 42%`).
* Dot+model and the ctx % are pinned and never gated here; the cwd is gated
* by its own leftover-width budget instead. */
export interface StatusSegments {
/** Full `███░░ 42% 84k` read-out; false → compact bare `42%`. */
/** Full `ctx: ███░░ 42% · 84k` read-out; false → compact `ctx: 42%`. */
ctxDetail: boolean
duration: boolean
compressions: boolean
cost: boolean
/** Session uptime (`up: 23m`). */
up: boolean
compressions: boolean
profile: boolean
/** Background-tasks count — reserved; no store data feeds it yet (see header). */
bg: boolean
@ -89,15 +81,24 @@ export function statusSegments(cols: number): StatusSegments {
const w = Math.max(1, Math.floor(cols || 1))
return {
ctxDetail: w >= 72,
duration: w >= 80,
compressions: w >= 80,
cost: w >= 92,
profile: w >= 100,
bg: w >= 108,
mcp: w >= 116
cost: w >= 80,
up: w >= 88,
compressions: w >= 94,
profile: w >= 108,
bg: w >= 118,
mcp: w >= 126
}
}
/** The context bar's cell count the chrome's most important gauge, sized to
* breathe: 14 cells on wide terminals, 12 at normal widths, 10 when tight. */
export function ctxBarCells(cols: number): number {
const w = Math.max(1, Math.floor(cols || 1))
if (w >= 160) return 14
if (w >= 100) return 12
return 10
}
/** Context-pressure level for the bar/% colour (spec thresholds 50/80/95). */
export type CtxLevel = 'ok' | 'warn' | 'bad' | 'critical'
export function ctxLevel(pct: number): CtxLevel {
@ -197,7 +198,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))
const barCells = createMemo(() => ctxBarCells(dims().width))
// ── transient update notice (borrows the whole line; Esc / TTL dismisses) ──
const [dismissed, setDismissed] = createSignal(false)
@ -219,7 +220,8 @@ export function StatusBar(props: { store: SessionStore }) {
if (key.name === 'escape' && noticeText()) setDismissed(true)
})
// ── segment texts (each '' when hidden/absent — also feeds the width budget) ──
// ── segment texts, in line order (each '' when hidden/absent — the plain
// string doubles as the width budget for the cwd tail) ──────────────
const model = () => {
const m = info().model
return m ? shortModel(m) : ''
@ -227,271 +229,116 @@ export function StatusBar(props: { store: SessionStore }) {
const effort = () => effortSuffix(info().effort, info().fast)
const pct = () => info().contextPercent
/** Plain text of the ctx segment (`ctx: ███░░ 42% · 84k` / `ctx: 42%`). */
const ctxText = createMemo(() => {
const p = pct()
if (p === undefined) return ''
if (!segs().ctxDetail) return `${p}%`
if (!segs().ctxDetail) return `ctx: ${p}%`
const used = info().contextUsed
return `${ctxBar(p, CTX_BAR_CELLS)} ${p}%${used !== undefined ? ` ${fmtTokens(used)}` : ''}`
return `ctx: ${ctxBar(p, barCells())} ${p}%${used !== undefined ? `${DOT_SEP}${fmtTokens(used)}` : ''}`
})
const costText = createMemo(() => {
const c = info().costUsd
return segs().cost && c !== undefined ? `$${c.toFixed(2)}` : ''
return segs().cost && c !== undefined ? `cost: $${c.toFixed(2)}` : ''
})
const durationText = createMemo(() => {
const upText = createMemo(() => {
const started = info().startedAt
if (!segs().duration || !started || !model()) return ''
if (!segs().up || !started || !model()) return ''
tick() // re-derive once per second while shown
return fmtShortDuration(elapsedSeconds(started))
return `up: ${fmtShortDuration(elapsedSeconds(started))}`
})
const cmpCount = () => info().compressions ?? 0
const cmpText = createMemo(() => (segs().compressions && cmpCount() > 0 ? `cmp ${cmpCount()}` : ''))
/** cost · duration · cmp as ONE bar segment (the spec's `$0.41 · 23m · cmp 2`). */
const meterText = createMemo(() => [costText(), durationText(), cmpText()].filter(Boolean).join(DOT_SEP))
const cmpText = createMemo(() => (segs().compressions && cmpCount() > 0 ? `cmp: ${cmpCount()}` : ''))
const profileText = createMemo(() => {
const p = info().profileName
return segs().profile && p && p !== 'default' && p !== 'custom' ? p : ''
})
const mcpText = createMemo(() => {
const n = info().mcpServers ?? 0
return segs().mcp && n > 0 ? `${n} mcp` : ''
return segs().mcp && n > 0 ? `mcp: ${n}` : ''
})
// Width budget for the right-aligned cwd: total minus box padding minus the
// plain-text width of every visible left segment (all monospace-1-col chars).
// The cwd flows LAST on the same line (not right-pinned): its budget is the
// row width minus every segment before it; it tail-truncates into that, and
// drops whole below CWD_MIN.
const leftLen = createMemo(() => {
let len = 1 // dot
if (model()) len += 1 + model().length + effort().length
for (const seg of [ctxText(), meterText(), profileText(), mcpText()]) {
for (const seg of [ctxText(), costText(), upText(), cmpText(), profileText(), mcpText()]) {
if (seg) len += SEP.length + seg.length
}
return len
})
const cwdFull = createMemo(() => {
const cwdText = createMemo(() => {
const cwd = info().cwd
const c = cwd ? shortCwd(cwd) : ''
if (!c) return ''
return info().branch ? `${c} (${info().branch})` : c
})
const rightText = createMemo(() => {
// dims() is the TERMINAL width; the bar's row is narrower by the app shell's
// horizontal padding (2) + this box's own padding (2), and we keep a 2-col
// gap so the cwd never butts against the left segments.
const budget = dims().width - 4 - leftLen() - 2
return budget > 4 ? truncLeft(cwdFull(), budget) : ''
const full = info().branch ? `${c} (${info().branch})` : c
const budget = dims().width - ROW_PADDING - leftLen() - SEP.length
return budget >= CWD_MIN ? truncLeft(full, 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 = () => (
<>
<Show when={costText()}>
<span style={{ fg: theme().color.muted }}>{costText()}</span>
</Show>
<Show when={costText() && durationText()}>
<span style={{ fg: theme().color.muted }}>{DOT_SEP}</span>
</Show>
<Show when={durationText()}>
<span style={{ fg: theme().color.muted }}>{durationText()}</span>
</Show>
<Show when={(costText() || durationText()) && cmpText()}>
<span style={{ fg: theme().color.muted }}>{DOT_SEP}</span>
</Show>
<Show when={cmpText()}>
<span style={{ fg: cmpColorOf(cmpCount()) }}>{cmpText()}</span>
</Show>
</>
/** A muted label + value span pair (`cost: $0.41`) with its leading ` │ `. */
const Seg = (p: { text: string; fg?: string }) => (
<Show when={p.text}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<span style={{ fg: p.fg ?? theme().color.muted }}>{p.text}</span>
</Show>
)
return (
<box
style={{
flexShrink: 0,
flexDirection: 'column',
flexDirection: 'row',
backgroundColor: theme().color.statusBg,
paddingLeft: 1,
paddingRight: 1
}}
>
<Show
when={mode() === 'wide'}
when={!noticeText()}
fallback={
/* MEDIUM/NARROW — the dense single row (statusSegments drop ladder). */
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<Show
when={!noticeText()}
fallback={
// the update notice borrows the WHOLE line (Variant A) — warn-tinted,
// head-truncated so the Esc hint clips last only on absurd widths.
<text selectable={false}>
<span style={{ fg: theme().color.warn }}>
{truncRight(noticeText(), Math.max(1, dims().width - 4))}
</span>
</text>
}
>
{/* left: pinned dot+model, then the priority-ordered tail segments */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<Show when={ctxText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
{/* ctxText() truthy guarantees pct() is defined; `?? 0` only satisfies the type. */}
<Show
when={segs().ctxDetail}
fallback={<span style={{ fg: ctxColorOf(pct() ?? 0) }}>{ctxText()}</span>}
>
<span style={{ fg: ctxColorOf(pct() ?? 0) }}>{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
<Show when={info().contextUsed !== undefined}>
<span style={{ fg: theme().color.muted }}>{` ${fmtTokens(info().contextUsed ?? 0)}`}</span>
</Show>
</Show>
</Show>
<Show when={meterText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<Meter />
</Show>
<Show when={profileText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
{/* statusFg, not accent persistent chrome spends no warm ink
(design pass); the navy fill is the bar's one blue surface. */}
<span style={{ fg: theme().color.statusFg }}>{profileText()}</span>
</Show>
{/* `N bg` would slot here (segs().bg) — no store data feeds it yet (see header). */}
<Show when={mcpText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<span style={{ fg: theme().color.muted }}>{mcpText()}</span>
</Show>
</text>
</box>
{/* spacer pushes the cwd to the right edge */}
<box style={{ flexGrow: 1, minWidth: 0 }} />
{/* right: cwd (branch), pre-truncated so the row never wraps */}
<Show when={rightText()}>
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
</text>
</box>
</Show>
</Show>
</box>
// the update notice borrows the WHOLE line — warn-tinted,
// head-truncated so the Esc hint clips last only on absurd widths.
<text selectable={false}>
<span style={{ fg: theme().color.warn }}>
{truncRight(noticeText(), Math.max(1, dims().width - ROW_PADDING))}
</span>
</text>
}
>
{/* WIDE line 1 session vitals in justified zones:
model·effort ctx bar + tokens cost·duration·cmp */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
</text>
</box>
<box style={{ flexGrow: 1, minWidth: 0 }} />
<Show when={wideCtx()}>
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<span style={{ fg: ctxColorOf(pct() ?? 0) }}>{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
<Show when={info().contextUsed !== undefined}>
<span style={{ fg: theme().color.muted }}>
{` ${fmtTokens(info().contextUsed ?? 0)}${
info().contextMax ? `/${fmtTokens(info().contextMax ?? 0)}` : ''
}`}
</span>
</Show>
</text>
</box>
{/* ONE left-flowing text run: dot+model, then the labeled segments in
priority order, the (pre-truncated) cwd last. No spacers, no pinning. */}
<text selectable={false}>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<box style={{ flexGrow: 1, minWidth: 0 }} />
<Show when={meterText()}>
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<Meter />
</text>
</box>
<Show when={ctxText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<span style={{ fg: theme().color.muted }}>{'ctx: '}</span>
{/* ctxText() truthy guarantees pct() is defined; `?? 0` only satisfies the type. */}
<Show when={segs().ctxDetail} fallback={<span style={{ fg: ctxColorOf(pct() ?? 0) }}>{`${pct()}%`}</span>}>
<span style={{ fg: ctxColorOf(pct() ?? 0) }}>{ctxBar(pct() ?? 0, barCells())}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
<Show when={info().contextUsed !== undefined}>
<span style={{ fg: theme().color.muted }}>{`${DOT_SEP}${fmtTokens(info().contextUsed ?? 0)}`}</span>
</Show>
</Show>
</Show>
</box>
{/* WIDE line 2 environment:
profile N mcp agents-running update-notice slot cwd (branch) */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<Show when={profileText()}>
<span style={{ fg: theme().color.statusFg }}>{profileText()}</span>
</Show>
<Show when={profileText() && (mcpText() || agentsText())}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
</Show>
<Show when={mcpText()}>
<span style={{ fg: theme().color.muted }}>{mcpText()}</span>
</Show>
<Show when={mcpText() && agentsText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
</Show>
<Show when={agentsText()}>
<span style={{ fg: theme().color.muted }}>{agentsText()}</span>
</Show>
</text>
</box>
<box style={{ flexGrow: 1, minWidth: 0 }} />
<Show when={noticeText() || wideCwd()}>
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
{/* the update notice rides line 2's slot in wide mode no line
borrowing; Esc/TTL dismisses it exactly as in medium. */}
<Show when={noticeText()}>
<span style={{ fg: theme().color.warn }}>{noticeText()}</span>
</Show>
<Show when={noticeText() && wideCwd()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
</Show>
<Show when={wideCwd()}>
<span style={{ fg: theme().color.muted }}>{wideCwd()}</span>
</Show>
</text>
</box>
</Show>
</box>
<Seg text={costText()} />
<Seg text={upText()} />
<Seg text={cmpText()} fg={cmpColorOf(cmpCount())} />
{/* statusFg, not accent persistent chrome spends no warm ink
(design pass); the navy fill is the bar's one blue surface. */}
<Seg text={profileText()} fg={theme().color.statusFg} />
{/* `bg: N` would slot here (segs().bg) — no store data feeds it yet (see header). */}
<Seg text={mcpText()} />
<Seg text={cwdText()} />
</text>
</Show>
</box>
)