diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 8dd908016e6..79da6876fec 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from 'ink-testing-library'; import type { DOMElement } from 'ink'; +import stringWidth from 'string-width'; import { BaseTextInput, defaultRenderLine, @@ -183,6 +184,49 @@ describe('BaseTextInput', () => { }), ).toEqual({ x: 29, y: 20 }); }); + + it.each([ + { columns: 11, label: 'session', expectedLabel: 'sessi…' }, + { columns: 12, label: '会话标题', expectedLabel: '会话标…' }, + { columns: 20, label: 'session', expectedLabel: 'session' }, + { columns: 4, label: 'session', expectedLabel: '' }, + ])( + 'keeps the top border within $columns columns for "$label"', + ({ columns, label, expectedLabel }) => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + 'columns', + ); + Object.defineProperty(process.stdout, 'columns', { + configurable: true, + value: columns, + }); + + try { + const { lastFrame } = render( + , + ); + const topBorderLine = lastFrame()?.split('\n')[0] ?? ''; + + expect(stringWidth(topBorderLine)).toBe(columns); + if (expectedLabel) { + expect(topBorderLine).toContain(` ${expectedLabel} `); + } else { + expect(topBorderLine).toBe('─'.repeat(columns)); + } + } finally { + if (originalDescriptor) { + Object.defineProperty(process.stdout, 'columns', originalDescriptor); + } else { + Reflect.deleteProperty(process.stdout, 'columns'); + } + } + }, + ); }); describe('getAbsolutePosition', () => { diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 5587ecf6f18..b72c978085e 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -28,10 +28,13 @@ import type { Key } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import stringWidth from 'string-width'; -import { cpSlice, cpLen } from '../utils/textUtils.js'; +import { cpSlice, cpLen, truncateToWidth } from '../utils/textUtils.js'; import { theme } from '../semantic-colors.js'; import { renderSoftwareCursor } from '../utils/software-cursor.js'; +const TOP_BORDER_LABEL_DECORATION_WIDTH = 4; +const TOP_BORDER_MIN_LEADING_DASHES = 1; + // ─── Types ────────────────────────────────────────────────── export interface RenderLineOptions { @@ -340,11 +343,18 @@ export const BaseTextInput = ({ const columns = process.stdout.columns || 80; // Build the top border line: ─────── label ── - // Label takes: 1 space + text + 1 space + 2 trailing dashes = label.length + 4 - const labelWidth = topRightLabel ? stringWidth(topRightLabel) + 4 : 0; - const dashCount = Math.max(1, columns - labelWidth); - const topBorderLine = topRightLabel - ? `${'─'.repeat(dashCount)} ${topRightLabel} ${'─'.repeat(2)}` + // Reserve the label decoration and at least one leading dash. + const labelBudget = + columns - TOP_BORDER_LABEL_DECORATION_WIDTH - TOP_BORDER_MIN_LEADING_DASHES; + const renderedLabel = topRightLabel + ? truncateToWidth(topRightLabel, labelBudget) + : ''; + const labelWidth = renderedLabel + ? stringWidth(renderedLabel) + TOP_BORDER_LABEL_DECORATION_WIDTH + : 0; + const dashCount = columns - labelWidth; + const topBorderLine = renderedLabel + ? `${'─'.repeat(dashCount)} ${renderedLabel} ${'─'.repeat(2)}` : '─'.repeat(columns); return (