diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 5fc5bd7531c..02af8cb26e1 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -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 ────────────────────────────────────────────────── @@ -371,11 +370,9 @@ export const BaseTextInput = ({ borderColor={resolvedBorderColor} > {resolvedPrefix} - + {/* No background fill: the input area blends into the terminal's own + background so it stays consistent across terminals and themes. */} + {buffer.text.length === 0 && placeholder ? ( showCursor ? ( diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 956e7abd981..3d6871c1057 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -234,7 +234,7 @@ const HistoryItemDisplayComponent: React.FC = ({ > {/* Render standard message types */} {itemForDisplay.type === 'user' && ( - + )} {itemForDisplay.type === 'notification' && ( diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index 38b7f101ec4..86e00bb3cc0 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -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, @@ -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'] || '', @@ -34,7 +28,6 @@ export const THINKING_ICON = interface UserMessageProps { text: string; - width?: number; } interface UserShellMessageProps { @@ -205,70 +198,19 @@ const ContinuationMarkdownMessage: React.FC< ); }; -export const UserMessage: React.FC = ({ 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 = ( - - ); - - 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 ( - - {'▄'.repeat(width)} - {lines.map((line, i) => { - const linePrefix = i === 0 ? prefix : ' '; - const lineWidth = stringWidth(linePrefix + line); - const pad = Math.max(0, width - lineWidth); - return ( - - - {linePrefix} - {line} - - {pad > 0 ? ' '.repeat(pad) : ''} - - ); - })} - {'▀'.repeat(width)} - - ); -}; +export const UserMessage: React.FC = ({ 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. + +); export const UserShellMessage: React.FC = ({ text }) => { const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts index 233b536d870..5886bfc32d5 100644 --- a/packages/cli/src/ui/themes/color-utils.test.ts +++ b/packages/cli/src/ui/themes/color-utils.test.ts @@ -7,8 +7,6 @@ import { describe, it, expect } from 'vitest'; import { interpolateColor, - subtleBandColor, - supportsTrueColor, isValidColor, resolveColor, CSS_NAME_TO_HEX_MAP, @@ -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'); - }); - }); }); diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts index 06db89765b2..c31a8b3a274 100644 --- a/packages/cli/src/ui/themes/color-utils.ts +++ b/packages/cli/src/ui/themes/color-utils.ts @@ -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); -} diff --git a/packages/cli/src/ui/utils/software-cursor.test.ts b/packages/cli/src/ui/utils/software-cursor.test.ts index a13fd4a4c0f..a1f09b5238e 100644 --- a/packages/cli/src/ui/utils/software-cursor.test.ts +++ b/packages/cli/src/ui/utils/software-cursor.test.ts @@ -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' } @@ -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'); - }); }); diff --git a/packages/cli/src/ui/utils/software-cursor.ts b/packages/cli/src/ui/utils/software-cursor.ts index d73f367dde5..056599f33f3 100644 --- a/packages/cli/src/ui/utils/software-cursor.ts +++ b/packages/cli/src/ui/utils/software-cursor.ts @@ -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'; @@ -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) { diff --git a/packages/cli/src/ui/utils/theme-background.test.ts b/packages/cli/src/ui/utils/theme-background.test.ts index 93a3e106423..f65229994e0 100644 --- a/packages/cli/src/ui/utils/theme-background.test.ts +++ b/packages/cli/src/ui/utils/theme-background.test.ts @@ -5,32 +5,8 @@ */ import { beforeEach, describe, expect, it } from 'vitest'; -import { - getEffectiveInputBackground, - getInputBackgroundFill, - themeBackgroundMatchesTerminal, -} from './theme-background.js'; +import { getEffectiveTerminalBackground } from './theme-background.js'; import { themeManager } from '../themes/theme-manager.js'; -import { theme } from '../semantic-colors.js'; -import type { CustomTheme } from '../themes/theme.js'; - -const customTheme: CustomTheme = { - type: 'custom', - name: 'MyCustomTheme', - Background: '#102030', - Foreground: '#ffffff', - LightBlue: '#89BDCD', - AccentBlue: '#3B82F6', - AccentPurple: '#8B5CF6', - AccentCyan: '#06B6D4', - AccentGreen: '#3CA84B', - AccentYellow: 'yellow', - AccentRed: 'red', - DiffAdded: 'green', - DiffRemoved: 'red', - Comment: 'gray', - Gray: 'gray', -}; // Force the terminal background detection result without probing the real // terminal: cachedAutoDetection takes precedence in getTerminalBackgroundType. @@ -56,70 +32,24 @@ describe('theme-background', () => { ).terminalBackground = undefined; }); - describe('themeBackgroundMatchesTerminal', () => { - it('matches when a light theme runs on a light terminal', () => { - themeManager.setActiveTheme('Qwen Light'); - setDetectedTerminal('light'); - expect(themeBackgroundMatchesTerminal()).toBe(true); - }); - - it('matches when a dark theme runs on a dark terminal', () => { - themeManager.setActiveTheme('Qwen Dark'); - setDetectedTerminal('dark'); - expect(themeBackgroundMatchesTerminal()).toBe(true); - }); - - it('does not match a light theme forced onto a dark terminal', () => { - themeManager.setActiveTheme('Qwen Light'); - setDetectedTerminal('dark'); - expect(themeBackgroundMatchesTerminal()).toBe(false); - }); - - it('does not match a dark theme forced onto a light terminal', () => { - themeManager.setActiveTheme('Qwen Dark'); - setDetectedTerminal('light'); - expect(themeBackgroundMatchesTerminal()).toBe(false); - }); - - it('treats custom themes as matching (brightness cannot be classified)', () => { - themeManager.loadCustomThemes({ MyCustomTheme: customTheme }); - themeManager.setActiveTheme('MyCustomTheme'); - setDetectedTerminal('light'); - expect(themeBackgroundMatchesTerminal()).toBe(true); - }); - }); - - describe('getInputBackgroundFill', () => { - it('fills with the theme background when it matches the terminal', () => { - themeManager.setActiveTheme('Qwen Light'); + describe('getEffectiveTerminalBackground', () => { + it('returns a light stand-in for a light terminal', () => { setDetectedTerminal('light'); - expect(getInputBackgroundFill()).toBe(theme.background.primary); - }); - - it('leaves the box transparent when the theme fights the terminal', () => { - themeManager.setActiveTheme('Qwen Light'); - setDetectedTerminal('dark'); - expect(getInputBackgroundFill()).toBeUndefined(); + expect(getEffectiveTerminalBackground()).toBe('#ffffff'); }); - }); - describe('getEffectiveInputBackground', () => { - it('returns the theme background when it matches the terminal', () => { - themeManager.setActiveTheme('Qwen Dark'); + it('returns a dark stand-in for a dark terminal', () => { setDetectedTerminal('dark'); - expect(getEffectiveInputBackground()).toBe(theme.background.primary); + expect(getEffectiveTerminalBackground()).toBe('#000000'); }); - it('returns a dark stand-in for a light theme on a dark terminal', () => { + it('does not depend on the active theme (no background is painted)', () => { + // Forcing a light theme onto a dark terminal must still report the + // terminal's own (dark) background, since the theme background is never + // painted. themeManager.setActiveTheme('Qwen Light'); setDetectedTerminal('dark'); - expect(getEffectiveInputBackground()).toBe('#000000'); - }); - - it('returns a light stand-in for a dark theme on a light terminal', () => { - themeManager.setActiveTheme('Qwen Dark'); - setDetectedTerminal('light'); - expect(getEffectiveInputBackground()).toBe('#ffffff'); + expect(getEffectiveTerminalBackground()).toBe('#000000'); }); }); }); diff --git a/packages/cli/src/ui/utils/theme-background.ts b/packages/cli/src/ui/utils/theme-background.ts index 5e2cc07082d..78d14d1d7bd 100644 --- a/packages/cli/src/ui/utils/theme-background.ts +++ b/packages/cli/src/ui/utils/theme-background.ts @@ -4,58 +4,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { theme } from '../semantic-colors.js'; import { themeManager } from '../themes/theme-manager.js'; // Representative terminal background colours used only for luminance-based -// decisions (e.g. software cursor contrast) when the active theme's background -// does not match the terminal and therefore isn't painted. Only the -// light/dark bucket matters, so pure black/white are the safest stand-ins. +// decisions (e.g. software cursor contrast). The TUI paints no background of +// its own, so anything sitting "on the input area" actually sits on the +// terminal's own background; the cursor must derive its contrast from that. +// Only the light/dark bucket matters, so pure black/white are the safest +// stand-ins. const DARK_TERMINAL_BACKGROUND = '#000000'; const LIGHT_TERMINAL_BACKGROUND = '#ffffff'; /** - * Whether the active theme's background brightness agrees with the terminal's - * detected background. + * A brightness-representative stand-in for the terminal's own background, + * derived from its detected dark/light type. * - * The TUI paints no global background — almost everything relies on the - * terminal's own background. So a component may only flood itself with the - * theme background when that background actually matches the terminal; - * otherwise (e.g. a user forcing "Qwen Light" onto a dark terminal) the fill - * renders as a bright block fighting the dark surroundings. - * - * Themes whose type is 'ansi' or 'custom' can't be classified as light/dark, - * so they're treated as matching to preserve existing behaviour. - */ -export function themeBackgroundMatchesTerminal(): boolean { - const activeType = themeManager.getActiveTheme().type; - if (activeType !== 'light' && activeType !== 'dark') { - return true; - } - return activeType === themeManager.getTerminalBackgroundType(); -} - -/** - * The colour to flood-fill the input box content area with, or `undefined` to - * leave it transparent so it blends into the terminal when the active theme - * fights the terminal background. - */ -export function getInputBackgroundFill(): string | undefined { - return themeBackgroundMatchesTerminal() - ? theme.background?.primary - : undefined; -} - -/** - * The brightness-representative colour the input area actually shows on screen: - * the theme background when it matches the terminal, otherwise a stand-in for - * the terminal's own background. Used for derived decisions such as software - * cursor contrast, which must stay correct even when no fill is painted. + * The TUI never floods a background of its own, so derived decisions such as + * software cursor contrast must be made against the terminal background the + * content actually renders on — not against the active theme's background, + * which is never painted. */ -export function getEffectiveInputBackground(): string { - if (themeBackgroundMatchesTerminal()) { - return theme.background?.primary ?? ''; - } +export function getEffectiveTerminalBackground(): string { return themeManager.getTerminalBackgroundType() === 'light' ? LIGHT_TERMINAL_BACKGROUND : DARK_TERMINAL_BACKGROUND;