diff --git a/ui-tui/package.json b/ui-tui/package.json index 126c71c2..a9e6a15c 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 2f5267f2..9338984b 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 68ff7926..fb0bc422 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 00000000..4dc22b7f --- /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 00000000..5960d391 --- /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 db108ad2..bbd2f094 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 222b9ecc..ca61830c 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 f0b20031..70412f52 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