diff --git a/ui-opentui/src/test/blockCopy.test.tsx b/ui-opentui/src/test/blockCopy.test.tsx index 16ecc237f10..3a294246fa7 100644 --- a/ui-opentui/src/test/blockCopy.test.tsx +++ b/ui-opentui/src/test/blockCopy.test.tsx @@ -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 { - 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']) diff --git a/ui-opentui/src/test/displayModes.test.tsx b/ui-opentui/src/test/displayModes.test.tsx index 32b8731792b..fad27b98068 100644 --- a/ui-opentui/src/test/displayModes.test.tsx +++ b/ui-opentui/src/test/displayModes.test.tsx @@ -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() } diff --git a/ui-opentui/src/test/statusChrome.test.tsx b/ui-opentui/src/test/statusChrome.test.tsx index 7b287c40f28..ada78436e85 100644 --- a/ui-opentui/src/test/statusChrome.test.tsx +++ b/ui-opentui/src/test/statusChrome.test.tsx @@ -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>]> = [ - [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 (10–14 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 () => { diff --git a/ui-opentui/src/view/messageLine.tsx b/ui-opentui/src/view/messageLine.tsx index 211af2950e2..d00bde83f6c 100644 --- a/ui-opentui/src/view/messageLine.tsx +++ b/ui-opentui/src/view/messageLine.tsx @@ -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 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 ( - copyBlock(props.source())}> + copyBlock(props.source())}> - + ⧉ copy ) @@ -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). - - - - {m().text} - - - + // 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. + + + {m().text} + + m().text} /> @@ -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 => ( - - - - - + + + t().text} /> diff --git a/ui-opentui/src/view/statusBar.tsx b/ui-opentui/src/view/statusBar.tsx index ded90215f18..953f2adcec6 100644 --- a/ui-opentui/src/view/statusBar.tsx +++ b/ui-opentui/src/view/statusBar.tsx @@ -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: + * 10–14 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 - * width→visibility 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 = () => ( - <> - - {costText()} - - - {DOT_SEP} - - - {durationText()} - - - {DOT_SEP} - - - {cmpText()} - - + /** A muted label + value span pair (`cost: $0.41`) with its leading ` │ `. */ + const Seg = (p: { text: string; fg?: string }) => ( + + {SEP} + {p.text} + ) return ( - - - {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()} - - - - - + // the update notice borrows the WHOLE line — warn-tinted, + // head-truncated so the Esc hint clips last only on absurd widths. + + + {truncRight(noticeText(), Math.max(1, dims().width - ROW_PADDING))} + + } > - {/* WIDE line 1 — session vitals in justified zones: - model·effort │ ctx bar + tokens │ cost·duration·cmp */} - - - - {dot()} - - {` ${model()}`} - {effort()} - - - - - - - - {ctxBar(pct() ?? 0, CTX_BAR_CELLS)} - {` ${pct()}%`} - - - {` ${fmtTokens(info().contextUsed ?? 0)}${ - info().contextMax ? `/${fmtTokens(info().contextMax ?? 0)}` : '' - }`} - - - - + {/* ONE left-flowing text run: dot+model, then the labeled segments in + priority order, the (pre-truncated) cwd last. No spacers, no pinning. */} + + {dot()} + + {` ${model()}`} + {effort()} - - - - - - - + + {SEP} + {'ctx: '} + {/* ctxText() truthy guarantees pct() is defined; `?? 0` only satisfies the type. */} + {`${pct()}%`}}> + {ctxBar(pct() ?? 0, barCells())} + {` ${pct()}%`} + + {`${DOT_SEP}${fmtTokens(info().contextUsed ?? 0)}`} + + - - - {/* WIDE line 2 — environment: - profile │ N mcp │ agents-running … update-notice slot │ cwd (branch) */} - - - - - {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()} - - - - - + + + + {/* statusFg, not accent — persistent chrome spends no warm ink + (design pass); the navy fill is the bar's one blue surface. */} + + {/* `bg: N` would slot here (segs().bg) — no store data feeds it yet (see header). */} + + + )