Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 88 additions & 16 deletions ui-tui/src/__tests__/brandingRenderWidth.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { PassThrough } from 'stream'

import { renderSync, stringWidth } from '@clawcodex/ink'
import { Box, renderSync, ScrollBox, stringWidth } from '@clawcodex/ink'
import React from 'react'
import { afterEach, describe, expect, it } from 'vitest'

import { SessionPanel } from '../components/branding.js'
import {
TRANSCRIPT_PADDING_X,
TRANSCRIPT_SCROLLBAR_GUTTER,
transcriptPanelWidth
} from '../lib/inputMetrics.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
import type { SessionInfo, Theme } from '../types.js'
Expand Down Expand Up @@ -58,7 +63,18 @@ const info = (over: Partial<SessionInfo> = {}): SessionInfo => ({
...over
})

async function boxRows(cols: number, sessionInfo: SessionInfo, theme: Theme = DEFAULT_THEME): Promise<string[]> {
const boxRows = (cols: number, sessionInfo: SessionInfo, theme: Theme = DEFAULT_THEME) =>
render(cols, transcriptPanelWidth(cols), sessionInfo, theme)

/** Same wrapper, but the panel is told a width its container does not have. */
const boxRowsWithMaxWidth = (cols: number, maxWidth: number) => render(cols, maxWidth, info(), DEFAULT_THEME)

async function render(
cols: number,
maxWidth: number,
sessionInfo: SessionInfo,
theme: Theme = DEFAULT_THEME
): Promise<string[]> {
Object.defineProperty(process.stdout, 'columns', { configurable: true, value: cols, writable: true })

const stdout = new PassThrough()
Expand All @@ -74,12 +90,41 @@ async function boxRows(cols: number, sessionInfo: SessionInfo, theme: Theme = DE
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
})
// Mirrors appLayout's actual wrapper, NOT the root and NOT a plain Box.
//
// Both details are load-bearing. Rendering at the root is what let a
// two-column overflow pass this file originally. And a plain <Box> stand-in
// is not equivalent either: it has flexShrink, so an over-wide child is
// quietly shrunk to fit and the bug disappears. ScrollBox constrains its
// children instead of being expanded by them, so an over-wide child is
// CLIPPED — which is why the right border vanished on screen but not in a
// Box-wrapped test.
const instance = renderSync(
React.createElement(
Box,
{ flexDirection: 'row', height: 40, width: cols },
React.createElement(
ScrollBox,
{ flexDirection: 'column', flexGrow: 1, flexShrink: 1 },
React.createElement(
Box,
{ flexDirection: 'column', paddingX: TRANSCRIPT_PADDING_X / 2 },
React.createElement(SessionPanel, { info: sessionInfo, maxWidth, sid: 'a1b2c3d4', t: theme })
)
),
React.createElement(Box, {
flexShrink: 0,
marginLeft: TRANSCRIPT_SCROLLBAR_GUTTER - 1,
width: TRANSCRIPT_SCROLLBAR_GUTTER - 1
})
),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)

try {
await delay(30)
Expand All @@ -90,8 +135,13 @@ async function boxRows(cols: number, sessionInfo: SessionInfo, theme: Theme = DE
// 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))
// Only the box rows; the panel also emits a trailing margin line. The
// wrapper's left padding means rows no longer start at column 0.
return plain
.split('\n')
.map(line => line.trimEnd())
.filter(line => /[╭│╰]/.test(line))
.map(line => line.slice(line.search(/[╭│╰]/)))
} finally {
instance.unmount()
instance.cleanup()
Expand All @@ -101,13 +151,14 @@ async function boxRows(cols: number, sessionInfo: SessionInfo, theme: Theme = DE
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 => {
it.each(WIDTHS)('fills its container exactly at %i terminal columns', async cols => {
const rows = await boxRows(cols, info())

expect(rows.length).toBeGreaterThan(0)

// The container, not the terminal: overflowing it clips the right border.
for (const row of rows) {
expect(stringWidth(row)).toBe(cols)
expect(stringWidth(row)).toBe(transcriptPanelWidth(cols))
}
})

Expand All @@ -130,6 +181,27 @@ describe('header box render width', () => {
}
})

// The shipped regression: appLayout passed `composer.cols - 2` (the COMPOSER's
// reserve) instead of the transcript's, so the panel believed it had two more
// columns than its container had. Combined with an explicit `width={cols}` on
// the box, the right border was pushed off screen — the box rendered with a
// left edge, a top rule running to the terminal edge, and no `╮`/`│`/`╯`.
//
// The panel must now survive a caller that over-reports: with no hard width it
// stretches to its real container regardless of what it was told.
it.each([34, 60, 100, 140])('ignores an over-reported maxWidth at %i columns', async cols => {
const rows = await boxRowsWithMaxWidth(cols, cols - 2)
const top = rows.find(r => r.startsWith('╭'))

expect(rows.length).toBeGreaterThan(0)
expect(top).toBeDefined()
expect(top?.endsWith('╮')).toBe(true)

for (const row of rows) {
expect(stringWidth(row)).toBe(transcriptPanelWidth(cols))
}
})

// 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' } }
Expand All @@ -139,7 +211,7 @@ describe('header box render width', () => {
const top = rows.find(r => r.startsWith('╭'))

expect(top).toBeDefined()
expect(stringWidth(top ?? '')).toBe(cols)
expect(stringWidth(top ?? '')).toBe(transcriptPanelWidth(cols))
expect(top?.endsWith('╮')).toBe(true)
}
})
Expand All @@ -154,7 +226,7 @@ describe('header box render width', () => {
expect(rows.length).toBeGreaterThan(0)

for (const row of rows) {
expect(stringWidth(row)).toBe(100)
expect(stringWidth(row)).toBe(transcriptPanelWidth(100))
}
})

Expand All @@ -164,7 +236,7 @@ describe('header box render width', () => {
expect(rows.length).toBeGreaterThan(0)

for (const row of rows) {
expect(stringWidth(row)).toBe(100)
expect(stringWidth(row)).toBe(transcriptPanelWidth(100))
}
})

Expand All @@ -178,7 +250,7 @@ describe('header box render width', () => {
expect(rows.length).toBeGreaterThan(0)

for (const row of rows) {
expect(stringWidth(row)).toBe(100)
expect(stringWidth(row)).toBe(transcriptPanelWidth(100))
}
}
})
Expand Down
27 changes: 26 additions & 1 deletion ui-tui/src/__tests__/termuxComposerLayout.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { stableComposerColumns, transcriptBodyWidth } from '../lib/inputMetrics.js'
import { stableComposerColumns, transcriptBodyWidth, transcriptPanelWidth } from '../lib/inputMetrics.js'
import { composerPromptText } from '../lib/prompt.js'

describe('Termux composer prompt + width guards', () => {
Expand Down Expand Up @@ -31,3 +31,28 @@ describe('Termux composer prompt + width guards', () => {
expect(transcriptBodyWidth(24, 'user', 'upstr >')).toBe(20)
})
})

describe('transcriptPanelWidth', () => {
// The transcript's reserve is NOT the composer's. appLayout used to hand the
// intro banner and session panel `composer.cols - 2` — the composer's outer
// padding — while the transcript actually gives up 4 columns: a scrollbar
// gutter (marginLeft 1 + width 1) plus paddingX on both sides. The boxed
// panel was therefore two columns too wide and its right border was clipped
// off the screen entirely.
it('reserves the scrollbar gutter and the transcript padding', () => {
expect(transcriptPanelWidth(100)).toBe(96)
expect(transcriptPanelWidth(80)).toBe(76)
})

it('is strictly narrower than the composer reserve it replaced', () => {
for (const cols of [40, 60, 100, 200]) {
expect(transcriptPanelWidth(cols)).toBeLessThan(cols - 2)
}
})

it('never goes below one column', () => {
for (const cols of [0, 1, 2, 3, 4, 5]) {
expect(transcriptPanelWidth(cols)).toBeGreaterThanOrEqual(1)
}
})
})
7 changes: 4 additions & 3 deletions ui-tui/src/components/appLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
COMPOSER_PROMPT_GAP_WIDTH,
composerPromptWidth,
inputVisualHeight,
stableComposerColumns
stableComposerColumns,
transcriptPanelWidth
} from '../lib/inputMetrics.js'
import { PerfPane } from '../lib/perfPane.js'
import { composerPromptText } from '../lib/prompt.js'
Expand Down Expand Up @@ -124,13 +125,13 @@ const TranscriptPane = memo(function TranscriptPane({

{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
<Banner logoPalette={ui.logoPalette} maxWidth={Math.max(1, composer.cols - 2)} t={ui.theme} />
<Banner logoPalette={ui.logoPalette} maxWidth={transcriptPanelWidth(composer.cols)} t={ui.theme} />

{row.msg.info && (
<SessionPanel
info={row.msg.info}
logoPalette={ui.logoPalette}
maxWidth={Math.max(1, composer.cols - 2)}
maxWidth={transcriptPanelWidth(composer.cols)}
sid={ui.sid}
t={ui.theme}
/>
Expand Down
32 changes: 24 additions & 8 deletions ui-tui/src/components/branding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export function SessionPanel({ info, logoPalette, maxWidth, sid, t }: SessionPan
[welcome, `${MODEL_LABEL}${modelLine}`, `${PATH_LABEL}${cwdLine}`, permsLine],
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
Expand Down Expand Up @@ -675,10 +676,13 @@ export function SessionPanel({ info, logoPalette, maxWidth, sid, t }: SessionPan
marginBottom={1}
paddingX={1}
paddingY={1}
// Without this the border stretches to the full terminal while the column
// math above is budgeted against `cols` (which honors maxWidth), so the
// right column stops short of its own border.
width={cols}
// Deliberately NO explicit width: the box stretches to its container, so
// the right border is wherever the container actually ends. Pinning it to
// `cols` instead makes the border a *prediction* of the container width,
// and when that prediction was two columns high (the transcript reserves a
// scrollbar gutter the composer budget doesn't) the right border was
// pushed off screen entirely. `cols` still drives the column split below,
// where being slightly off only shifts a truncation point.
>
{wide && (
<Box alignItems="center" flexDirection="column" width={leftW}>
Expand All @@ -700,7 +704,10 @@ export function SessionPanel({ info, logoPalette, maxWidth, sid, t }: SessionPan
/>
)}

<Box flexDirection="column" width={w}>
{/* flexGrow so the feed column absorbs the container's *actual* remainder
rather than the `w` we predicted; `w` stays the flex basis and the
truncation budget. */}
<Box flexDirection="column" flexGrow={1} flexShrink={1} width={w}>
{/* Narrow layout drops the column split; keep the identity block above
the feed so nothing is lost. */}
{!wide && (
Expand All @@ -722,9 +729,18 @@ export function SessionPanel({ info, logoPalette, maxWidth, sid, t }: SessionPan
</Box>
))}

<Box marginTop={1}>
<Text color={t.color.border}>{'─'.repeat(Math.max(1, w))}</Text>
</Box>
{/* A top-border-only box, not `'─'.repeat(w)`: the column can flex away
from `w`, and a repeat-count rule would then under- or overshoot its
own column. A border stretches to whatever width the column got. */}
<Box
borderBottom={false}
borderColor={t.color.border}
borderLeft={false}
borderRight={false}
borderStyle="single"
borderTop
marginTop={1}
/>

<Text color={t.color.text}>
{toolsTotal} tools{' · '}
Expand Down
18 changes: 18 additions & 0 deletions ui-tui/src/lib/inputMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,24 @@ export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: s
return Math.max(20, available)
}

/** Scrollbar gutter beside the transcript: `marginLeft={1}` + `width={1}` on
* the `TranscriptScrollbar` wrapper in appLayout. Always reserved — the
* scrollbar renders a `width={1}` spacer even when it has nothing to show. */
export const TRANSCRIPT_SCROLLBAR_GUTTER = 2
/** `paddingX={1}` on the transcript's inner column, both sides. */
export const TRANSCRIPT_PADDING_X = 2

/** Width actually available to a full-bleed transcript child (the intro banner
* and session panel), i.e. the ScrollBox interior.
*
* Not the same as `stableComposerColumns`, which budgets the *composer's* text
* area and subtracts the prompt prefix. Passing `totalCols - 2` here — the
* composer's reserve rather than the transcript's — makes a boxed child two
* columns too wide, which silently clips its right border off the screen. */
export function transcriptPanelWidth(totalCols: number) {
return Math.max(1, totalCols - TRANSCRIPT_SCROLLBAR_GUTTER - TRANSCRIPT_PADDING_X)
}

export function stableComposerColumns(totalCols: number, promptWidth: number, termuxMode = false) {
// Physical render/wrap width. Always reserve outer composer padding and
// prompt prefix. Only reserve the transcript scrollbar gutter when the
Expand Down
Loading