From dea83bd77639a281c51a9d9881a3b4bbfd60b665 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Tue, 28 Jul 2026 23:00:41 -0700 Subject: [PATCH] feat(tui): rework the header box to the reference element allocation The startup header sized its left column as `min(mascotWidth + 4, cols * 0.4)`, which never looked at the content it had to hold. At 140 columns that pinned it to 20 and truncated the cwd to `/Users/ericlee2/wor...` while ~65 columns sat empty to the right, and the panel title floated inside the box over the right column only. Port the allocation math from the in-repo reference (`calculateOptimalLeftWidth` / `calculateLayoutDimensions` in typescript/src/utils/logoV2Utils.ts): size the left column to its own widest line, book the divider and padding explicitly, and hand the remainder to the feed column. The title moves into the top border, the identity block gains a welcome line and labeled Model/Path rows, and a rule separates the columns. Section content is unchanged -- only the allocation. Four defects surfaced while building it, each pinned by a test that fails when the fix is reverted: - Collapse toggles were sibling es in a flex row, so an overflowing toggle wrapped each span into its own column ("|Available (8) in 2" / " Skills categories") instead of truncating. Now one nested run. - Splitting was gated on terminal width alone. Because the left column is sized to the cwd, a deep path wants 45+ columns at a terminal width of 72; forcing the split there overflowed the box and flexbox resolved it by shrinking both children. `fitsHorizontal` now also requires the columns to fit, and stacks otherwise. - ink stops embedding `borderText` once the title reaches `cols - 2` and instead returns the raw string with the corners dropped (render-border.ts:43-44), leaving a box with no top edge. `brand.name` is operator-configurable, so a branded install hit this at ordinary widths. `borderTitleParts` degrades name+version -> name -> none. - Path truncation clipped by code unit, so a CJK cwd overshot its budget and got re-truncated at the far end -- eliding both ends and destroying the trailing segment middle-truncation exists to preserve. It also split surrogate pairs. Clipping now walks grapheme clusters via Intl.Segmenter, the same segmenter stringWidth uses internally. Supporting changes: export `applyColor` from the vendored ink fork so the border title is tinted through the fork's terminal-aware downgrade rather than a hand-rolled truecolor escape; `artWidth` measures columns now that it feeds layout sizing; drop the dead `release_date` field, whose only consumer was the old title; add a `pretest` hook so a stale gitignored ink `dist/` cannot fail the suite with an ESM link error. brandingRenderWidth.test.ts documents a trap worth knowing: `useStdout()` returns `process.stdout`, not the stream passed to `renderSync`, so the usual `Object.assign(passThrough, {columns})` harness leaves the component on its `?? 100` fallback and every "render at width N" silently produces the same 100-column layout. Suite: 8 failed / 1545 passed -- the same 8 pre-existing failures as baseline on 11c3bedd (8 failed / 1493 passed), +52 from the two new files. Co-Authored-By: Claude Opus 5 --- ui-tui/package.json | 2 + ui-tui/packages/clawcodex-ink/index.d.ts | 1 + .../clawcodex-ink/src/entry-exports.ts | 1 + ui-tui/src/__tests__/brandingLayout.test.ts | 257 +++++++++ .../src/__tests__/brandingRenderWidth.test.ts | 185 +++++++ ui-tui/src/banner.ts | 7 +- ui-tui/src/components/branding.tsx | 487 ++++++++++++++---- ui-tui/src/types.ts | 1 - 8 files changed, 834 insertions(+), 107 deletions(-) create mode 100644 ui-tui/src/__tests__/brandingLayout.test.ts create mode 100644 ui-tui/src/__tests__/brandingRenderWidth.test.ts diff --git a/ui-tui/package.json b/ui-tui/package.json index 126c71c23..a9e6a15c9 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -12,7 +12,9 @@ "lint:fix": "eslint src/ packages/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'", "fix": "npm run lint:fix && npm run fmt", + "pretest": "npm run build --prefix packages/clawcodex-ink", "test": "vitest run", + "pretest:watch": "npm run build --prefix packages/clawcodex-ink", "test:watch": "vitest" }, "dependencies": { diff --git a/ui-tui/packages/clawcodex-ink/index.d.ts b/ui-tui/packages/clawcodex-ink/index.d.ts index 2f5267f23..9338984bd 100644 --- a/ui-tui/packages/clawcodex-ink/index.d.ts +++ b/ui-tui/packages/clawcodex-ink/index.d.ts @@ -6,6 +6,7 @@ export type { StdoutHandle } from './src/hooks/use-stdout.ts' export { Ansi } from './src/ink/Ansi.tsx' export { evictInkCaches } from './src/ink/cache-eviction.ts' export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts' +export { applyColor } from './src/ink/colorize.ts' export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx' export { default as Box } from './src/ink/components/Box.tsx' export type { Props as BoxProps } from './src/ink/components/Box.tsx' diff --git a/ui-tui/packages/clawcodex-ink/src/entry-exports.ts b/ui-tui/packages/clawcodex-ink/src/entry-exports.ts index 68ff79264..fb0bc422f 100644 --- a/ui-tui/packages/clawcodex-ink/src/entry-exports.ts +++ b/ui-tui/packages/clawcodex-ink/src/entry-exports.ts @@ -2,6 +2,7 @@ export { default as useStderr } from './hooks/use-stderr.js' export { default as useStdout } from './hooks/use-stdout.js' export { Ansi } from './ink/Ansi.js' export { evictInkCaches, type EvictLevel, type InkCacheSizes } from './ink/cache-eviction.js' +export { applyColor } from './ink/colorize.js' export { AlternateScreen } from './ink/components/AlternateScreen.js' export { default as Box } from './ink/components/Box.js' export { default as Link } from './ink/components/Link.js' diff --git a/ui-tui/src/__tests__/brandingLayout.test.ts b/ui-tui/src/__tests__/brandingLayout.test.ts new file mode 100644 index 000000000..4dc22b7f6 --- /dev/null +++ b/ui-tui/src/__tests__/brandingLayout.test.ts @@ -0,0 +1,257 @@ +import { stringWidth } from '@clawcodex/ink' +import { describe, expect, it } from 'vitest' + +import { + borderTitleParts, + fitsHorizontal, + optimalLeftWidth, + rightColumnWidth, + truncatePath, + welcomeMessage +} from '../components/branding.js' + +// The header box allocates width the way the reference banner does +// (typescript/src/utils/logoV2Utils.ts): the left column is sized to its own +// content, the divider and padding are booked explicitly, and the remainder +// goes to the feed column. +// +// Regression this file pins: the old code sized the left column as +// `min(mascotWidth + 4, cols * 0.4)`, which never looked at the cwd or model +// lines. At 140 columns that pinned it to 20, truncating the cwd to +// `/Users/ericlee2/wor…` while ~65 columns sat empty on the right. + +const HERO_W = 16 + +describe('optimalLeftWidth', () => { + it('sizes to the widest line, not to the mascot', () => { + const cwd = 'Path /Users/ericlee2/workspace/clawcodex' + + // Widest line + 4 for padding — and well clear of the mascot, which is the + // whole point: the old code returned mascotWidth + 4 = 20 here. + expect(optimalLeftWidth([cwd, 'Model opus'], HERO_W)).toBe(cwd.length + 4) + expect(optimalLeftWidth([cwd, 'Model opus'], HERO_W)).toBeGreaterThan(HERO_W + 4) + }) + + // HERO_W must exceed MIN_LEFT_CONTENT (20) or this is tautological: at 16 the + // floor dominates and the assertion passes even if heroWidth is ignored. + it('never drops below the mascot, so a wide custom hero still fits', () => { + const wideHero = 30 + + expect(optimalLeftWidth(['x'], wideHero)).toBe(wideHero + 4) + }) + + it('caps so one deep path cannot starve the feed column', () => { + const deep = `Path /${'nested/'.repeat(40)}leaf` + + expect(optimalLeftWidth([deep], HERO_W)).toBe(50) + }) +}) + +describe('rightColumnWidth', () => { + it('hands the remainder to the feed column', () => { + // 138 terminal cols - BORDER_PADDING(4) - DIVIDER_MARGIN(2) - DIVIDER_WIDTH(1) + // - left(46) = 85. + expect(rightColumnWidth(138, 46)).toBe(85) + }) + + it('grows as the terminal grows', () => { + expect(rightColumnWidth(198, 46)).toBeGreaterThan(rightColumnWidth(138, 46)) + }) +}) + +describe('fitsHorizontal', () => { + it('splits when both columns fit', () => { + expect(fitsHorizontal(140, 46)).toBe(true) + }) + + it('stacks below the coarse threshold', () => { + expect(fitsHorizontal(60, 20)).toBe(false) + }) + + // The regression that motivated the fit check: at 72 columns a deep cwd wants + // a 45-wide left column, so the split overflowed and flexbox shrank BOTH + // children -- wrapping the collapse toggles into unreadable two-word columns. + it('stacks when the terminal clears the threshold but the columns do not fit', () => { + expect(fitsHorizontal(72, 45)).toBe(false) + }) + + it('is monotonic in terminal width for a fixed left column', () => { + const widths = [70, 80, 90, 100, 120, 160] + const results = widths.map(c => fitsHorizontal(c, 45)) + + expect(results).toEqual([...results].sort((a, b) => Number(a) - Number(b))) + }) + + it('leaves the feed column at least its minimum whenever it splits', () => { + for (let cols = 20; cols <= 220; cols++) { + for (const leftW of [20, 32, 45, 50]) { + if (fitsHorizontal(cols, leftW)) { + expect(rightColumnWidth(cols, leftW)).toBeGreaterThanOrEqual(36) + } + } + } + }) + + // Guards the overflow that mangles the toggles: when the layout does split, + // the pieces must fit the interior (terminal - border - paddingX). + it('never lets the split columns overflow the box interior', () => { + for (let cols = 20; cols <= 220; cols++) { + for (const leftW of [20, 32, 45, 50]) { + if (fitsHorizontal(cols, leftW)) { + // DIVIDER_MARGIN(2) + DIVIDER_WIDTH(1); the render test in + // brandingRenderWidth.test.ts is what pins this against the real JSX. + const gutter = 3 + + expect(leftW + gutter + rightColumnWidth(cols, leftW)).toBeLessThanOrEqual(cols - 4) + } + } + } + }) +}) + +describe('truncatePath', () => { + it('leaves a path that already fits alone', () => { + expect(truncatePath('/a/b/c', 40)).toBe('/a/b/c') + }) + + // The point of middle-truncation: end-truncation would hide `components`, + // the one segment that tells you where you are. + it('keeps the trailing segments, dropping from the front', () => { + const out = truncatePath('/Users/me/workspace/clawcodex/ui-tui/src/components', 30) + + expect(out.startsWith('…/')).toBe(true) + expect(out.endsWith('components')).toBe(true) + expect(stringWidth(out)).toBeLessThanOrEqual(30) + }) + + it('clips the final segment when not even it fits', () => { + const out = truncatePath('/Users/me/averyveryverylongdirectoryname', 12) + + expect(stringWidth(out)).toBeLessThanOrEqual(12) + expect(out.startsWith('…')).toBe(true) + }) + + it('respects the budget across a sweep of widths', () => { + const path = '/Users/ericlee2/workspace/clawcodex/ui-tui/src/components/deeply/nested' + + for (let w = 6; w <= 80; w++) { + expect(stringWidth(truncatePath(path, w))).toBeLessThanOrEqual(w) + } + }) + + // These measure COLUMNS, not code units. A code-unit clip (`.slice` by + // `.length`) overshoots on wide glyphs and returns a string wider than the + // budget; ink then truncates it again at the far end, eliding both ends and + // destroying the trailing segment this function exists to preserve. + it('respects the budget in columns for wide glyphs', () => { + const cjk = `/Users/me/${'目'.repeat(30)}/项目目录` + + for (let w = 4; w <= 60; w++) { + expect(stringWidth(truncatePath(cjk, w))).toBeLessThanOrEqual(w) + } + }) + + it('respects the budget in columns when the final segment must be clipped', () => { + // One segment, all wide glyphs — forces the clip branch specifically. + const wide = `/Users/me/${'目'.repeat(40)}` + + for (const w of [5, 12, 20, 33, 41]) { + expect(stringWidth(truncatePath(wide, w))).toBeLessThanOrEqual(w) + } + }) + + it('never splits a surrogate pair', () => { + for (let w = 3; w <= 30; w++) { + const out = truncatePath(`/Users/me/${'🎉'.repeat(20)}`, w) + + expect(out).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/) + expect(out).not.toMatch(/(? { + for (let w = 2; w <= 30; w++) { + expect(stringWidth(truncatePath(`/Users/me/${'1️⃣'.repeat(12)}`, w))).toBeLessThanOrEqual(w) + } + }) + + // Regional-indicator flags are pairs; splitting them yields reversed letters. + it('keeps regional-indicator flags intact', () => { + const out = truncatePath(`/Users/me/${'🇸🇪'.repeat(10)}`, 20) + + expect(stringWidth(out)).toBeLessThanOrEqual(20) + // An odd number of regional indicators means a pair was cut in half. + expect((out.match(/[\u{1F1E6}-\u{1F1FF}]/gu) ?? []).length % 2).toBe(0) + }) + + it('keeps combining marks attached to their base', () => { + for (let w = 3; w <= 24; w++) { + const out = truncatePath(`/Users/me/${'é'.repeat(15)}`, w) // e + U+0301 + + expect(stringWidth(out)).toBeLessThanOrEqual(w) + expect(out).not.toMatch(/^[̀-ͯ]/) + } + }) + + it('returns nothing for a non-positive budget', () => { + expect(truncatePath('/a/b/c', 0)).toBe('') + expect(truncatePath('/a/b/c', -5)).toBe('') + }) + + it('handles degenerate paths', () => { + expect(truncatePath('', 10)).toBe('') + expect(stringWidth(truncatePath('noslashes-but-really-quite-long', 10))).toBeLessThanOrEqual(10) + expect(stringWidth(truncatePath('/Users/me/trailing/', 8))).toBeLessThanOrEqual(8) + }) +}) + +describe('borderTitleParts', () => { + // Guards the collapse at `cols - 2`, where ink drops the corners and returns + // the raw title — leaving the box with no top edge at all. + it('keeps name and version when there is room', () => { + expect(borderTitleParts(100, 'clawcodex', '1.0.0')).toEqual({ name: 'clawcodex', version: '1.0.0' }) + }) + + it('drops the version before dropping the name', () => { + expect(borderTitleParts(20, 'clawcodex', '1.0.0')).toEqual({ name: 'clawcodex' }) + }) + + it('gives up entirely rather than collapse the border', () => { + expect(borderTitleParts(12, 'clawcodex', '1.0.0')).toBeNull() + expect(borderTitleParts(30, 'Acme Engineering Assistant', '1.2.1')).toBeNull() + }) + + it('never returns a title that reaches ink\'s collapse threshold', () => { + for (let cols = 8; cols <= 200; cols++) { + for (const name of ['clawcodex', 'Acme Engineering Assistant', '目目目目目目目目']) { + const parts = borderTitleParts(cols, name, '1.2.1') + + if (parts) { + const rendered = ` ${parts.name}${parts.version ? ` v${parts.version}` : ''} ` + + expect(stringWidth(rendered)).toBeLessThan(cols - 2) + } + } + } + }) +}) + +describe('welcomeMessage', () => { + it('greets a known user by name', () => { + expect(welcomeMessage('ericlee2', 'clawcodex')).toBe('Welcome back, ericlee2') + }) + + it('falls back when there is no username', () => { + expect(welcomeMessage(null, 'clawcodex')).toBe('Welcome to clawcodex') + expect(welcomeMessage('', 'clawcodex')).toBe('Welcome to clawcodex') + }) + + // A pathological name would otherwise blow out the left column's sizing. + it('falls back rather than let an absurd name size the column', () => { + expect(welcomeMessage('x'.repeat(64), 'clawcodex')).toBe('Welcome to clawcodex') + }) +}) diff --git a/ui-tui/src/__tests__/brandingRenderWidth.test.ts b/ui-tui/src/__tests__/brandingRenderWidth.test.ts new file mode 100644 index 000000000..5960d391f --- /dev/null +++ b/ui-tui/src/__tests__/brandingRenderWidth.test.ts @@ -0,0 +1,185 @@ +import { PassThrough } from 'stream' + +import { renderSync, stringWidth } from '@clawcodex/ink' +import React from 'react' +import { afterEach, describe, expect, it } from 'vitest' + +import { SessionPanel } from '../components/branding.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' +import type { SessionInfo, Theme } from '../types.js' + +// Render-level invariant: every row the header box emits is exactly `cols` +// wide, with its border glyphs intact, at every width and for every cwd. +// +// This is the test that actually holds the layout. The unit tests in +// brandingLayout.test.ts pin the arithmetic, but the arithmetic is only correct +// relative to the JSX chrome (border 1+1, paddingX 1+1, divider marginX 1+1) — +// change `marginX={1}` to `2` and those still pass while the box overflows. +// +// It also pins the border-title guard: ink stops embedding `borderText` once +// the title reaches `cols - 2` and instead returns the raw title with the +// corners dropped (render-border.ts:43-44), which blanks the top row entirely. +// +// NOTE: SessionPanel reads its width from useStdout(), which is hardcoded to +// `process.stdout` — NOT the stream passed to renderSync. Assigning `columns` +// to the PassThrough (the pattern in brandingMcpCount.test.ts) leaves the +// component on its `?? 100` fallback, so every "render at width N" silently +// produces the same 100-column layout clipped to N. It looks responsive and +// tests nothing. Pin process.stdout.columns instead. + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +const originalColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns') + +afterEach(() => { + if (originalColumns) { + Object.defineProperty(process.stdout, 'columns', originalColumns) + + return + } + + // When stdout is piped — CI, and any plain `npm test` — process.stdout has no + // OWN `columns` descriptor, so the restore branch above never fires and the + // property this file defines stays pinned at the last width used for the rest + // of the vitest worker, which is reused across files. Delete it instead. + delete (process.stdout as { columns?: number }).columns +}) + +const info = (over: Partial = {}): SessionInfo => ({ + cwd: '/Users/ericlee2/workspace/clawcodex', + mcp_servers: [{ connected: true, name: 'nous-support', status: 'connected', tools: 6, transport: 'http' }], + model: 'anthropic/claude-opus-5', + profile_name: 'Claude Max', + skills: { core: ['a', 'b'] }, + system_prompt: 'You are clawcodex.', + tools: { file_tools: ['Read', 'Write', 'Edit'], shell_tools: ['Bash'] }, + version: '1.0.0', + ...over +}) + +async function boxRows(cols: number, sessionInfo: SessionInfo, theme: Theme = DEFAULT_THEME): Promise { + Object.defineProperty(process.stdout, 'columns', { configurable: true, value: cols, writable: true }) + + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + Object.assign(stdout, { columns: cols, isTTY: false, rows: 60 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + + let captured = '' + stdout.on('data', chunk => { + captured += chunk.toString() + }) + + const instance = renderSync(React.createElement(SessionPanel, { info: sessionInfo, sid: 'a1b2c3d4', t: theme }), { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + }) + + try { + await delay(30) + + // stripAnsi, not a hand-rolled /\[[0-9;]*m/ — that leaves the ESC byte, so + // rows still start with an escape and the `^[╭│╰]` filter below silently + // drops them. A vacuous `rows.find(...)` then makes the corner assertions + // pass against `undefined`. + const plain = stripAnsi(captured) + + // Only the box rows; the panel also emits a trailing margin line. + return plain.split('\n').filter(line => /^[╭│╰]/.test(line)) + } finally { + instance.unmount() + instance.cleanup() + } +} + +const WIDTHS = [34, 40, 60, 72, 88, 96, 100, 140, 200] + +describe('header box render width', () => { + it.each(WIDTHS)('emits rows exactly %i columns wide', async cols => { + const rows = await boxRows(cols, info()) + + expect(rows.length).toBeGreaterThan(0) + + for (const row of rows) { + expect(stringWidth(row)).toBe(cols) + } + }) + + it.each(WIDTHS)('keeps the border corners and edges intact at %i columns', async cols => { + const rows = await boxRows(cols, info()) + const top = rows.find(r => r.startsWith('╭')) + const bottom = rows.find(r => r.startsWith('╰')) + + // Non-vacuity: if the rows were filtered away these assertions would + // otherwise "pass" against undefined. + expect(top).toBeDefined() + expect(bottom).toBeDefined() + + // The border-title collapse manifests exactly here: no corners on the top row. + expect(top?.endsWith('╮')).toBe(true) + expect(bottom?.endsWith('╯')).toBe(true) + + for (const row of rows.filter(r => r.startsWith('│'))) { + expect(row.endsWith('│')).toBe(true) + } + }) + + // A branded install pushes the title past `cols - 2` at ordinary widths. + it('degrades a long brand title instead of destroying the top border', async () => { + const branded: Theme = { ...DEFAULT_THEME, brand: { ...DEFAULT_THEME.brand, name: 'Acme Engineering Assistant' } } + + for (const cols of [30, 36, 40, 60]) { + const rows = await boxRows(cols, info(), branded) + const top = rows.find(r => r.startsWith('╭')) + + expect(top).toBeDefined() + expect(stringWidth(top ?? '')).toBe(cols) + expect(top?.endsWith('╮')).toBe(true) + } + }) + + // The clip-by-code-unit bug showed up only here: an over-wide truncated path + // re-truncates at the far end, eliding both ends and losing the tail segment. + it('keeps rows exact for a CJK cwd', async () => { + const rows = await boxRows(100, info({ cwd: `/Users/me/${'目'.repeat(30)}/项目` })) + + // Non-vacuity: a `for…of` over an empty array asserts nothing, and these two + // are the tests guarding the width-aware clip. + expect(rows.length).toBeGreaterThan(0) + + for (const row of rows) { + expect(stringWidth(row)).toBe(100) + } + }) + + it('keeps rows exact for an emoji cwd', async () => { + const rows = await boxRows(100, info({ cwd: `/Users/me/${'🎉'.repeat(20)}/folder` })) + + expect(rows.length).toBeGreaterThan(0) + + for (const row of rows) { + expect(stringWidth(row)).toBe(100) + } + }) + + // Keycap sequences undercount per code point (digit + VS16 + U+20E3 sums to 1 + // column, the cluster is 2), and regional-indicator flags split into reversed + // letter pairs. Both are grapheme-cluster failures, not code-point ones. + it('keeps rows exact for keycap and flag cwds', async () => { + for (const dir of ['1️⃣'.repeat(12), '🇸🇪'.repeat(10)]) { + const rows = await boxRows(100, info({ cwd: `/Users/me/${dir}` })) + + expect(rows.length).toBeGreaterThan(0) + + for (const row of rows) { + expect(stringWidth(row)).toBe(100) + } + } + }) +}) diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts index db108ad24..bbd2f0948 100644 --- a/ui-tui/src/banner.ts +++ b/ui-tui/src/banner.ts @@ -1,3 +1,5 @@ +import { stringWidth } from '@clawcodex/ink' + import { DEFAULT_LOGO_PALETTE, gradientStopForRow, @@ -142,6 +144,9 @@ export const lobster = (c: ThemeColors, customHero?: string, logoColor?: string) return colorize(LOBSTER_ART, LOBSTER_GRADIENT, c) } -export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0) +// Measured in columns, not code units: this feeds the header's left-column +// sizing (`optimalLeftWidth`), so a custom `bannerHero` skin with wide glyphs +// would otherwise under-size the column it has to fit inside. +export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, stringWidth(t)), 0) type Line = [string, string] diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 222b9ecc7..ca61830ce 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -1,4 +1,6 @@ -import { Box, Text, useStdout } from '@clawcodex/ink' +import { userInfo } from 'os' + +import { applyColor, Box, stringWidth, Text, useStdout } from '@clawcodex/ink' import { useEffect, useState } from 'react' import unicodeSpinners from 'unicode-animations' @@ -142,18 +144,207 @@ function CollapseToggle({ title: string onToggle: () => void }) { + // One run, not sibling es in a flex row: as siblings, a toggle + // wider than its column makes flexbox wrap each span into its own column + // ("▸Available (8) in 2" / " Skills categories") instead of truncating + // the line. Nesting keeps it a single run that truncates cleanly. return ( - {open ? '▾ ' : '▸ '} - - {title} + + {open ? '▾ ' : '▸ '} + + {title} + + {typeof count === 'number' ? ({count}) : null} + {suffix ? {suffix} : null} - {typeof count === 'number' ? ({count}) : null} - {suffix ? {suffix} : null} ) } +// ── Header layout allocation ───────────────────────────────────────── +// +// Ported from the reference banner's allocation math +// (`calculateOptimalLeftWidth` / `calculateLayoutDimensions` in +// typescript/src/utils/logoV2Utils.ts). +// +// The point is that the left column is sized to *its own content* and the +// remainder is handed to the right column, with the divider and padding booked +// explicitly. The previous code sized the left column as +// `min(mascotWidth + 4, cols * 0.4)`, which never looked at the cwd or model +// lines: at 140 columns that pinned it to 20, truncating the cwd to +// `/Users/ericlee2/wor…` while ~65 columns sat empty to the right. + +const MAX_LEFT_WIDTH = 50 +/** Floor so a short cwd still leaves the mascot room to breathe. */ +const MIN_LEFT_CONTENT = 20 +/** The box's own chrome: `borderStyle` (1 each side) + `paddingX={1}`. */ +const BORDER_PADDING = 4 +/** The divider's `marginX={1}`, both sides. Must track the JSX below. */ +const DIVIDER_MARGIN = 2 +/** The divider's own border glyph. Must track the JSX below. */ +const DIVIDER_WIDTH = 1 +/** A space either side of the left column's text so it doesn't touch the rule. */ +const LEFT_COLUMN_SLACK = 2 +// The reference floors the feed column at 30, but its feed titles are short; +// clawcodex's longest toggle ("▸ Available Skills (8) in 2 categories") is 38. +// Below this the split earns nothing, so the layout stacks instead. +const MIN_RIGHT_WIDTH = 36 +/** Below this the columns stack — the divider costs more width than it earns. */ +const HORIZONTAL_FROM = 70 +const MAX_USERNAME_LENGTH = 20 +// Row labels. Kept as constants because the width budgets are measured off them. +const MODEL_LABEL = 'Model ' +const PATH_LABEL = 'Path ' +/** Columns between the top-left corner and the title. */ +const TITLE_OFFSET = 3 + +/** Widest title variant that `borderText` can render, or null if none fits. + * + * This guard is not cosmetic. Once the title reaches `cols - 2` ink stops + * embedding it and returns `['', text.substring(0, cols), '']` + * (render-border.ts:43-44) — corners and rule gone, and the slice is by code unit + * across a pre-rendered ANSI string. The result is a box with no top edge at + * all. `t.brand.name` is operator-configurable, so a branded install hits this + * at ordinary narrow-pane widths, not just tiny ones. Degrade instead: + * name + version → name → nothing. */ +export function borderTitleParts(cols: number, name: string, version?: string): { name: string; version?: string } | null { + // Keeps ink's `position` clamp (render-border.ts:59) from moving the title. + const room = cols - TITLE_OFFSET - 2 + const width = (v?: string) => stringWidth(` ${name}${v ? ` v${v}` : ''} `) + + if (version && width(version) <= room) { + return { name, version } + } + + return width() <= room ? { name } : null +} + +/** Split into columns only when the terminal clears the coarse threshold AND + * both columns fit at usable widths. + * + * The fit check is not redundant with the threshold: the left column is sized + * to the cwd, so a deep path can want 50 columns at a terminal width of 72. + * Forcing the split there overflows the box, and flexbox resolves the overflow + * by shrinking *both* children — which wraps the collapse toggles into + * unreadable two-word columns ("▸Available (8 in 2" / " Skills categories"). + * Stacking is the better failure mode. */ +export function fitsHorizontal(columns: number, leftWidth: number): boolean { + const needed = leftWidth + BORDER_PADDING + DIVIDER_MARGIN + DIVIDER_WIDTH + MIN_RIGHT_WIDTH + + return columns >= HORIZONTAL_FROM && needed <= columns +} + +/** Left column width: its widest line, floored at the mascot, capped so a deep + * cwd can never starve the feed column. */ +export function optimalLeftWidth(lines: string[], heroWidth: number): number { + const content = Math.max(...lines.map(stringWidth), heroWidth, MIN_LEFT_CONTENT) + + return Math.min(content + 4, MAX_LEFT_WIDTH) +} + +/** Whatever the left column and chrome don't use goes to the feed column. */ +export function rightColumnWidth(columns: number, leftWidth: number): number { + const used = BORDER_PADDING + DIVIDER_MARGIN + DIVIDER_WIDTH + leftWidth + + return Math.max(MIN_RIGHT_WIDTH, columns - used) +} + +/** Grapheme clusters of `s`, longest tail fitting in `maxWidth` *columns*. + * + * Every part of that sentence is load-bearing: + * + * - *columns*, not code units. Slicing by `.length` overshoots on a CJK cwd + * ("目" is 1 unit, 2 columns) and returns a string wider than the budget, + * which then re-truncates at the far end and elides BOTH sides — destroying + * the trailing segment this whole function exists to preserve. + * - *grapheme clusters*, not code points. Measuring per code point undercounts + * a keycap sequence (`1️⃣` = digit + VS16 + U+20E3 sums to 1, but the cluster + * is 2 columns wide), which overflows again; and it splits regional-indicator + * flags into reversed letter pairs. `Intl.Segmenter` is what `stringWidth` + * itself segments with, so this measures the way the renderer measures. + * - Code points at minimum, never code units: an arbitrary code-unit offset + * leaves a lone surrogate that renders as U+FFFD but measures as zero. */ +const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + +function clipTailToWidth(s: string, maxWidth: number): string { + if (maxWidth <= 0) { + return '' + } + + const clusters = [...GRAPHEMES.segment(s)].map(g => g.segment) + let out = '' + let width = 0 + + for (let i = clusters.length - 1; i >= 0; i--) { + const ch = clusters[i]! + const chWidth = stringWidth(ch) + + if (width + chWidth > maxWidth) { + break + } + + out = ch + out + width += chWidth + } + + return out +} + +/** Middle-truncate a path so the *current* directory stays readable. + * + * Plain `wrap="truncate-end"` clips the tail, which is the one segment that + * matters: `/Users/me/workspace/clawcodex/ui-tui/src/comp…` tells you less + * than `…/ui-tui/src/components`. Mirrors the reference's `truncatePath` + * (logoV2Utils.ts), keeping as many trailing segments as fit. */ +export function truncatePath(path: string, maxWidth: number): string { + if (maxWidth <= 0) { + return '' + } + + if (stringWidth(path) <= maxWidth) { + return path + } + + const parts = path.split('/') + const last = parts[parts.length - 1] ?? '' + + // Even one segment behind the ellipsis doesn't fit — clip the tail itself. + if (stringWidth(`…/${last}`) > maxWidth) { + return `…${clipTailToWidth(last, maxWidth - 1)}` + } + + // Grow backwards from the tail while whole segments still fit. + let kept = last + + for (let i = parts.length - 2; i > 0; i--) { + const next = `${parts[i]}/${kept}` + + if (stringWidth(`…/${next}`) > maxWidth) { + break + } + + kept = next + } + + return `…/${kept}` +} + +/** Mirrors the reference's `formatWelcomeMessage`: an absurdly long name is + * treated as no name at all rather than blowing out the left column. */ +export function welcomeMessage(username: string | null | undefined, brand: string): string { + return !username || username.length > MAX_USERNAME_LENGTH ? `Welcome to ${brand}` : `Welcome back, ${username}` +} + +/** `os.userInfo()` throws when the uid has no passwd entry (some containers). */ +function currentUsername(): string | null { + try { + return userInfo().username || null + } catch { + return null + } +} + // ── SessionPanel ───────────────────────────────────────────────────── const SKILLS_MAX = 8 @@ -163,12 +354,44 @@ export function SessionPanel({ info, logoPalette, maxWidth, sid, t }: SessionPan const term = useStdout().stdout?.columns ?? 100 const cols = Math.max(20, Math.min(term, maxWidth ?? term)) const heroLines = lobster(t.color, t.bannerHero || undefined, logoPalette) - const leftW = Math.min((artWidth(heroLines) || LOBSTER_WIDTH) + 4, Math.floor(cols * 0.4)) - const wide = cols >= 90 && leftW + 40 < cols - const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12) + const heroW = artWidth(heroLines) || LOBSTER_WIDTH + + // Left-column lines, built before layout so the column can be sized to them. + // The cwd is pre-truncated to the column's hard cap: sizing the column to an + // arbitrarily deep path first and clipping after would let one long path + // dictate the whole split. + const welcome = welcomeMessage(currentUsername(), t.brand.name) + const modelLine = `${info.model.split('/').pop()}${info.profile_name ? ` · ${info.profile_name}` : ''}` + const rawCwd = info.cwd || process.cwd() + const cwdLine = truncatePath(rawCwd, MAX_LEFT_WIDTH - LEFT_COLUMN_SLACK - PATH_LABEL.length) + + const leftW = optimalLeftWidth([welcome, `${MODEL_LABEL}${modelLine}`, `${PATH_LABEL}${cwdLine}`], heroW) + const wide = fitsHorizontal(cols, leftW) + // Stacked: the column IS the interior (border + paddingX = BORDER_PADDING). + // Flooring this at MIN_RIGHT_WIDTH would overflow a narrow box — that floor + // gates whether to split at all, it is not a width to render at. + const w = wide ? rightColumnWidth(cols, leftW) : Math.max(10, cols - BORDER_PADDING) + // Stacked layout spans `w`, which can be narrower than the left column's cap. + const shownCwd = wide ? cwdLine : truncatePath(rawCwd, Math.max(10, w - PATH_LABEL.length)) const lineBudget = Math.max(12, w - 2) const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) + // Pre-rendered ANSI: `borderText` writes straight into the border run, so it + // can't take children. applyColor is the fork's own colorizer, which + // keeps the 8-bit downgrade for legacy Apple Terminal that a hand-rolled + // truecolor escape would bypass. + // + // The cast is real, not cosmetic: theme colors are typed `string` but + // applyColor wants the `Color` union, which the ink entry does not export. + // Runtime is safe either way — colorize() returns the text unstyled for any + // format it doesn't recognize, so a bad skin color degrades to plain text. + const tint = (s: string, color: string) => applyColor(s, color as Parameters[1]) + const titleParts = borderTitleParts(cols, t.brand.name, info.version) + + const borderTitle = titleParts + ? ` ${tint(titleParts.name, t.color.text)}${titleParts.version ? ` ${tint(`v${titleParts.version}`, t.color.muted)}` : ''} ` + : null + // ── Local collapse state for each section ── const [toolsOpen, setToolsOpen] = useState(true) const [skillsOpen, setSkillsOpen] = useState(false) @@ -287,79 +510,20 @@ export function SessionPanel({ info, logoPalette, maxWidth, sid, t }: SessionPan return {info.system_prompt} } - return ( - - {wide && ( - - - - - - {info.model.split('/').pop()} - {info.profile_name ? · {info.profile_name} : null} - - - - {info.cwd || process.cwd()} - - - {worktree && ( - - worktree {worktree.name} · {worktree.branch} - - )} - - {sid && ( - - Session: - {sid} - - )} - - )} - - - {wide ? ( - - - {t.brand.name} - {info.version ? ` v${info.version}` : ''} - {info.release_date ? ` (${info.release_date})` : ''} - - - ) : ( - // Narrow layout hides the hero column; surface model/cwd/session - // here so they aren't lost. - - - {info.model.split('/').pop()} - {info.profile_name ? · {info.profile_name} : null} - - - {info.cwd || process.cwd()} - - {worktree && ( - - worktree {worktree.name} · {worktree.branch} - - )} - {sid && ( - - Session: - {sid} - - )} - - )} - - {/* ── Tools (expanded by default) ── */} - + const sections: { key: string; node: React.ReactNode }[] = [ + { + key: 'tools', + node: ( + <> setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools" /> {toolsOpen && toolsBody()} - - - {/* ── Skills (collapsed by default) ── */} - + + ) + }, + { + key: 'skills', + node: ( + <> setSkillsOpen(v => !v)} @@ -371,38 +535,151 @@ export function SessionPanel({ info, logoPalette, maxWidth, sid, t }: SessionPan title="Available Skills" /> {skillsOpen && skillsBody()} + + ) + }, + ...(sysPromptLen > 0 + ? [ + { + key: 'system', + node: ( + <> + setSystemOpen(v => !v)} + open={systemOpen} + suffix={`— ${sysPromptLen.toLocaleString()} chars`} + t={t} + title="System Prompt" + /> + {systemOpen && systemBody()} + + ) + } + ] + : []), + ...(mcpServers.length > 0 + ? [ + { + key: 'mcp', + node: ( + <> + setMcpOpen(v => !v)} + open={mcpOpen} + suffix="connected" + t={t} + title="MCP Servers" + /> + {mcpOpen && mcpBody()} + + ) + } + ] + : []) + ] + + // Identity block: welcome + mascot + labeled Model/Path rows. Shared by both + // layouts so the narrow one loses the *column*, never the information. + const identity = ( + <> + + {welcome} + + + + + + + {'─'.repeat(12)} + + + {MODEL_LABEL} + {modelLine} + + + + {PATH_LABEL} + {shownCwd} + + + {worktree && ( + + Tree + {worktree.name} + · {worktree.branch} + + )} + + {sid && ( + + Session + {sid} + + )} + + ) + + return ( + + {wide && ( + + {identity} + )} + + {/* Column rule. A left-only border stretches to the taller column, so the + rule always spans the full content height without being measured. */} + {wide && ( + + )} - {/* ── System Prompt (collapsed by default) ── */} - {sysPromptLen > 0 && ( - - setSystemOpen(v => !v)} - open={systemOpen} - suffix={`— ${sysPromptLen.toLocaleString()} chars`} - t={t} - title="System Prompt" - /> - {systemOpen && systemBody()} + + {/* Narrow layout drops the column split; keep the identity block above + the feed so nothing is lost. */} + {!wide && ( + + {identity} )} - {/* ── MCP Servers (collapsed by default) ── */} - {mcpServers.length > 0 && ( - - setMcpOpen(v => !v)} - open={mcpOpen} - suffix="connected" - t={t} - title="MCP Servers" - /> - {mcpOpen && mcpBody()} + {/* Built as a list so a hidden section takes its spacing with it — an + inline `&&` leaves a dangling gap. The first section sits flush with + the top of the column so both columns start on the same row. + Sections are spaced, not ruled: the reference draws one rule between + two multi-line feeds, and clawcodex's mostly-collapsed toggles would + turn a rule-per-section into four rules of noise. The single rule + below separates the index from the totals footer. */} + {sections.map((section, i) => ( + 0 ? 1 : 0}> + {section.node} - )} + ))} - + + {'─'.repeat(Math.max(1, w))} + {toolsTotal} tools{' · '} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index f0b200315..70412f522 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -226,7 +226,6 @@ export interface SessionInfo { permission_mode?: string profile_name?: string reasoning_effort?: string - release_date?: string service_tier?: string skills: Record system_prompt?: string