mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): status chrome v3 — one left-aligned labeled line; copy chip off the scrollbar edge
This commit is contained in:
parent
31e0adc681
commit
7e01a96e53
5 changed files with 270 additions and 400 deletions
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = () => (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue