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
9 changes: 3 additions & 6 deletions packages/cli/src/ui/components/BaseTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import stringWidth from 'string-width';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { theme } from '../semantic-colors.js';
import { renderSoftwareCursor } from '../utils/software-cursor.js';
import { getInputBackgroundFill } from '../utils/theme-background.js';

// ─── Types ──────────────────────────────────────────────────

Expand Down Expand Up @@ -371,11 +370,9 @@ export const BaseTextInput = ({
borderColor={resolvedBorderColor}
>
{resolvedPrefix}
<Box
flexGrow={1}
flexDirection="column"
backgroundColor={getInputBackgroundFill()}
>
{/* No background fill: the input area blends into the terminal's own
background so it stays consistent across terminals and themes. */}
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
>
{/* Render standard message types */}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} width={contentWidth} />
<UserMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'notification' && (
<InfoMessage text={itemForDisplay.text} />
Expand Down
86 changes: 14 additions & 72 deletions packages/cli/src/ui/components/messages/ConversationMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import type React from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import {
MarkdownDisplay,
Expand All @@ -16,15 +16,9 @@ import {
SCREEN_READER_MODEL_PREFIX,
SCREEN_READER_USER_PREFIX,
} from '../../textConstants.js';
import {
resolveColor,
subtleBandColor,
supportsTrueColor,
} from '../../themes/color-utils.js';
import { t } from '../../../i18n/index.js';
import { getCachedStringWidth } from '../../utils/textUtils.js';
import { formatDuration } from '../../utils/displayUtils.js';
import { themeBackgroundMatchesTerminal } from '../../utils/theme-background.js';

const isUtf8 = /utf-?8/i.test(
process.env['LANG'] || process.env['LC_ALL'] || '',
Expand All @@ -34,7 +28,6 @@ export const THINKING_ICON =

interface UserMessageProps {
text: string;
width?: number;
}

interface UserShellMessageProps {
Expand Down Expand Up @@ -205,70 +198,19 @@ const ContinuationMarkdownMessage: React.FC<
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The simplified UserMessage only destructures { text }, but UserMessageProps (line 32) still declares width?: number and HistoryItemDisplay.tsx:237 still passes width={contentWidth} — the prop is silently ignored. Consider removing width from the interface and updating the caller so TypeScript enforces a clean contract.

— qwen3.7-max via Qwen Code /review

};

export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();

const useBand =
width !== undefined &&
width > 0 &&
!isScreenReaderEnabled &&
!!theme.background.primary &&
// The band paints the theme background behind every user message; only do
// so when it matches the terminal, else (e.g. a light theme forced onto a
// dark terminal) it renders as bright stripes fighting the surroundings.
themeBackgroundMatchesTerminal() &&
supportsTrueColor();

const fallback = (
<PrefixedTextMessage
text={text}
prefix=">"
prefixColor={theme.text.accent}
textColor={theme.text.accent}
ariaLabel={SCREEN_READER_USER_PREFIX}
alignSelf="flex-start"
marginTop={1}
/>
);

if (!useBand) {
return fallback;
}

const bg = resolveColor(theme.background.primary) || theme.background.primary;
const bandColor = subtleBandColor(bg);
if (!bandColor) {
return fallback;
}

const prefix = '> ';
const lines = text.split('\n');

return (
<Box flexDirection="column" width={width}>
<Text color={bandColor}>{'▄'.repeat(width)}</Text>
{lines.map((line, i) => {
const linePrefix = i === 0 ? prefix : ' ';
const lineWidth = stringWidth(linePrefix + line);
const pad = Math.max(0, width - lineWidth);
return (
<Text
key={i}
backgroundColor={bandColor}
aria-label={i === 0 ? SCREEN_READER_USER_PREFIX : undefined}
>
<Text color={theme.text.accent}>
{linePrefix}
{line}
</Text>
{pad > 0 ? ' '.repeat(pad) : ''}
</Text>
);
})}
<Text color={bandColor}>{'▀'.repeat(width)}</Text>
</Box>
);
};
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => (
// The TUI paints no background of its own; user messages render directly on
// the terminal background so they blend in across terminals and themes.
<PrefixedTextMessage
text={text}
prefix=">"
prefixColor={theme.text.accent}
textColor={theme.text.accent}
ariaLabel={SCREEN_READER_USER_PREFIX}
alignSelf="flex-start"
marginTop={1}
/>
);

export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
Expand Down
32 changes: 0 additions & 32 deletions packages/cli/src/ui/themes/color-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
import { describe, it, expect } from 'vitest';
import {
interpolateColor,
subtleBandColor,
supportsTrueColor,
isValidColor,
resolveColor,
CSS_NAME_TO_HEX_MAP,
Expand Down Expand Up @@ -256,34 +254,4 @@ describe('Color Utils', () => {
expect(interpolateColor('#ffffff', 'notacolor', 0.5)).toBe('');
});
});

describe('subtleBandColor', () => {
it('shifts dark background toward white', () => {
const result = subtleBandColor('#000000');
expect(result).toMatch(/^#[0-9a-f]{6}$/);
expect(result).not.toBe('#000000');
const r = parseInt(result.slice(1, 3), 16);
expect(r).toBeGreaterThan(0);
expect(r).toBeLessThan(30);
});

it('shifts light background toward black', () => {
const result = subtleBandColor('#ffffff');
expect(result).toMatch(/^#[0-9a-f]{6}$/);
expect(result).not.toBe('#ffffff');
const r = parseInt(result.slice(1, 3), 16);
expect(r).toBeGreaterThan(225);
expect(r).toBeLessThan(255);
});

it('returns empty string for unparseable input', () => {
expect(subtleBandColor('notacolor')).toBe('');
});
});

describe('supportsTrueColor', () => {
it('returns a boolean', () => {
expect(typeof supportsTrueColor()).toBe('boolean');
});
});
});
42 changes: 0 additions & 42 deletions packages/cli/src/ui/themes/color-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,45 +313,3 @@ export function interpolateColor(
Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
return `#${toByte(lerp(r1, r2))}${toByte(lerp(g1, g2))}${toByte(lerp(b1, b2))}`;
}

/**
* Computes a subtle band color by shifting the background brightness toward
* white (dark themes) or black (light themes) by `factor` (default 0.06).
* No hue change — just a brightness nudge, so the band is nearly invisible.
* Automatically detects dark/light from the background color luminance.
* Returns '' if the background color cannot be resolved.
*/
export function subtleBandColor(bgColor: string, factor = 0.06): string {
const hex = toHex(bgColor);
if (!hex) {
return '';
}
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const isDark = (r * 299 + g * 587 + b * 114) / 1000 < 128;
const target = isDark ? '#ffffff' : '#000000';
return interpolateColor(hex, target, factor);
}

/**
* Detects whether the terminal supports 24-bit (true) color, required for the
* blended half-line background band. Result is cached at module scope since
* terminal color capability does not change during the process lifetime.
*/
let _supportsTrueColor: boolean | undefined;
export function supportsTrueColor(): boolean {
if (_supportsTrueColor !== undefined) return _supportsTrueColor;
const colorterm = process.env['COLORTERM'];
if (
colorterm === 'truecolor' ||
colorterm === '24bit' ||
colorterm === 'kmscon'
) {
return (_supportsTrueColor = true);
}
if (process.stdout.getColorDepth && process.stdout.getColorDepth() >= 24) {
return (_supportsTrueColor = true);
}
return (_supportsTrueColor = false);
}
24 changes: 11 additions & 13 deletions packages/cli/src/ui/utils/software-cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('renderSoftwareCursor', () => {
});
});

describe('getSoftwareCursorBackground theme-derived default', () => {
describe('getSoftwareCursorBackground terminal-derived default', () => {
function setDetectedTerminal(value: 'dark' | 'light') {
(
themeManager as unknown as { cachedAutoDetection: 'dark' | 'light' }
Expand All @@ -66,24 +66,22 @@ describe('getSoftwareCursorBackground theme-derived default', () => {
).terminalBackground = undefined;
});

it('contrasts against the theme background when it matches the terminal', () => {
themeManager.setActiveTheme('Qwen Dark');
it('uses a light cursor on a dark terminal', () => {
setDetectedTerminal('dark');
expect(getSoftwareCursorBackground()).toBe('#D4D4D4');
});

it('stays visible (light cursor) for a light theme forced onto a dark terminal', () => {
it('uses a dark cursor on a light terminal', () => {
setDetectedTerminal('light');
expect(getSoftwareCursorBackground()).toBe('#3A3A3A');
});

it('derives contrast from the terminal, not the active theme', () => {
// The TUI never paints the theme background, so a light theme forced onto a
// dark terminal must still yield a light cursor that stays visible on the
// dark terminal.
themeManager.setActiveTheme('Qwen Light');
setDetectedTerminal('dark');
// Without the terminal-aware default this would contrast against the light
// theme background and render a dark, near-invisible cursor on the dark
// terminal.
expect(getSoftwareCursorBackground()).toBe('#D4D4D4');
});

it('stays visible (dark cursor) for a dark theme forced onto a light terminal', () => {
themeManager.setActiveTheme('Qwen Dark');
setDetectedTerminal('light');
expect(getSoftwareCursorBackground()).toBe('#3A3A3A');
});
});
4 changes: 2 additions & 2 deletions packages/cli/src/ui/utils/software-cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import chalk from 'chalk';
import { resolveColor } from '../themes/color-utils.js';
import { getEffectiveInputBackground } from './theme-background.js';
import { getEffectiveTerminalBackground } from './theme-background.js';

const LIGHT_CURSOR_BACKGROUND = '#D4D4D4';
const DARK_CURSOR_BACKGROUND = '#3A3A3A';
Expand Down Expand Up @@ -51,7 +51,7 @@ function toHex(color: string): string | undefined {
}

export function getSoftwareCursorBackground(
backgroundColor = getEffectiveInputBackground(),
backgroundColor = getEffectiveTerminalBackground(),
): string {
const hex = backgroundColor ? toHex(backgroundColor) : undefined;
if (!hex) {
Expand Down
Loading
Loading