Skip to content
Closed
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
89 changes: 65 additions & 24 deletions packages/color-diff-napi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ type Theme = {

function defaultSyntaxThemeName(themeName: string): string {
if (themeName.includes('ansi')) return 'ansi'
if (themeName.includes('dark')) return 'Monokai Extended'
if (themeName.includes('dark')) return 'Royal Gold Dark'
return 'GitHub'
}

Expand Down Expand Up @@ -221,6 +221,35 @@ const MONOKAI_SCOPES: Record<string, Color> = {
subst: rgb(248, 248, 242),
}

// Custom dark theme for the TUI: lower saturation, richer gold accents, and
// cooler blue-green contrast so code feels more refined on black backgrounds.
const ROYAL_GOLD_DARK_SCOPES: Record<string, Color> = {
keyword: rgb(254, 200, 74),
_storage: rgb(135, 195, 255),
built_in: rgb(135, 195, 255),
type: rgb(135, 195, 255),
literal: rgb(224, 164, 88),
number: rgb(224, 164, 88),
string: rgb(246, 224, 176),
title: rgb(235, 200, 141),
'title.function': rgb(235, 200, 141),
'title.class': rgb(235, 200, 141),
'title.class.inherited': rgb(235, 200, 141),
params: rgb(243, 240, 232),
comment: rgb(139, 125, 107),
meta: rgb(139, 125, 107),
attr: rgb(135, 195, 255),
attribute: rgb(135, 195, 255),
variable: rgb(243, 240, 232),
'variable.language': rgb(243, 240, 232),
property: rgb(243, 240, 232),
operator: rgb(231, 185, 76),
punctuation: rgb(229, 223, 211),
symbol: rgb(224, 164, 88),
regexp: rgb(246, 224, 176),
subst: rgb(229, 223, 211),
}

// highlight.js scope → syntect GitHub-light foreground (measured from Rust)
const GITHUB_SCOPES: Record<string, Color> = {
keyword: rgb(167, 29, 93),
Expand Down Expand Up @@ -286,6 +315,18 @@ const ANSI_SCOPES: Record<string, Color> = {
meta: ansiIdx(8),
}

// Brand colors for diff highlighting
const BRAND_DIFF_RED = rgb(162, 0, 67)
const BRAND_DIFF_GREEN = rgb(34, 139, 34)
const BRAND_DIFF_RED_DARK_LINE = rgb(92, 0, 38)
const BRAND_DIFF_RED_DARK_WORD = rgb(132, 0, 54)
const BRAND_DIFF_GREEN_DARK_LINE = rgb(10, 74, 41)
const BRAND_DIFF_GREEN_DARK_WORD = rgb(16, 110, 60)
const BRAND_DIFF_RED_LIGHT_LINE = rgb(242, 220, 230)
const BRAND_DIFF_RED_LIGHT_WORD = rgb(228, 170, 196)
const BRAND_DIFF_GREEN_LIGHT_LINE = rgb(220, 238, 220)
const BRAND_DIFF_GREEN_LIGHT_WORD = rgb(170, 214, 170)

function buildTheme(themeName: string, mode: ColorMode): Theme {
const isDark = themeName.includes('dark')
const isAnsi = themeName.includes('ansi')
Expand All @@ -308,57 +349,57 @@ function buildTheme(themeName: string, mode: ColorMode): Theme {

if (isDark) {
const fg = rgb(248, 248, 242)
const deleteLine = rgb(61, 1, 0)
const deleteWord = rgb(92, 2, 0)
const deleteDecoration = rgb(220, 90, 90)
const deleteLine = BRAND_DIFF_RED_DARK_LINE
const deleteWord = BRAND_DIFF_RED_DARK_WORD
const deleteDecoration = BRAND_DIFF_RED
if (isDaltonized) {
return {
addLine: tc ? rgb(0, 27, 41) : ansiIdx(17),
addWord: tc ? rgb(0, 48, 71) : ansiIdx(24),
addDecoration: rgb(81, 160, 200),
deleteLine,
deleteWord,
deleteDecoration,
deleteLine: rgb(61, 1, 0),
deleteWord: rgb(92, 2, 0),
deleteDecoration: rgb(220, 90, 90),
foreground: fg,
background: DEFAULT_BG,
scopes: MONOKAI_SCOPES,
scopes: ROYAL_GOLD_DARK_SCOPES,
}
}
return {
addLine: tc ? rgb(2, 40, 0) : ansiIdx(22),
addWord: tc ? rgb(4, 71, 0) : ansiIdx(28),
addDecoration: rgb(80, 200, 80),
addLine: tc ? BRAND_DIFF_GREEN_DARK_LINE : BRAND_DIFF_GREEN_DARK_LINE,
addWord: tc ? BRAND_DIFF_GREEN_DARK_WORD : BRAND_DIFF_GREEN_DARK_WORD,
addDecoration: BRAND_DIFF_GREEN,
deleteLine,
deleteWord,
deleteDecoration,
foreground: fg,
background: DEFAULT_BG,
scopes: MONOKAI_SCOPES,
scopes: ROYAL_GOLD_DARK_SCOPES,
}
}

// light
const fg = rgb(51, 51, 51)
const deleteLine = rgb(255, 220, 220)
const deleteWord = rgb(255, 199, 199)
const deleteDecoration = rgb(207, 34, 46)
const deleteLine = BRAND_DIFF_RED_LIGHT_LINE
const deleteWord = BRAND_DIFF_RED_LIGHT_WORD
const deleteDecoration = BRAND_DIFF_RED
if (isDaltonized) {
return {
addLine: rgb(219, 237, 255),
addWord: rgb(179, 217, 255),
addDecoration: rgb(36, 87, 138),
deleteLine,
deleteWord,
deleteDecoration,
addLine: BRAND_DIFF_GREEN_LIGHT_LINE,
addWord: BRAND_DIFF_GREEN_LIGHT_WORD,
addDecoration: BRAND_DIFF_GREEN,
deleteLine: rgb(255, 220, 220),
deleteWord: rgb(255, 199, 199),
deleteDecoration: rgb(207, 34, 46),
foreground: fg,
background: DEFAULT_BG,
scopes: GITHUB_SCOPES,
}
}
return {
addLine: rgb(220, 255, 220),
addWord: rgb(178, 255, 178),
addDecoration: rgb(36, 138, 61),
addLine: BRAND_DIFF_GREEN_LIGHT_LINE,
addWord: BRAND_DIFF_GREEN_LIGHT_WORD,
addDecoration: BRAND_DIFF_GREEN,
deleteLine,
deleteWord,
deleteDecoration,
Expand Down
2 changes: 1 addition & 1 deletion src/components/FullscreenLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ export function FullscreenLayout({
ref={scrollRef}
flexGrow={1}
flexDirection="column"
paddingTop={padCollapsed ? 0 : 1}
paddingTop={0}
stickyScroll
>
<ScrollChromeContext value={chromeCtx}>
Expand Down
13 changes: 3 additions & 10 deletions src/components/HighlightedCode.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as React from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useSettings } from '../hooks/useSettings.js'
import {
Ansi,
Box,
Expand Down Expand Up @@ -34,20 +33,14 @@ export const HighlightedCode = memo(function HighlightedCode({
const ref = useRef<DOMElement>(null)
const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH)
const [theme] = useTheme()
const settings = useSettings()
const syntaxHighlightingDisabled =
settings.syntaxHighlightingDisabled ?? false

const colorFile = useMemo(() => {
if (syntaxHighlightingDisabled) {
return null
}
const ColorFile = expectColorFile()
if (!ColorFile) {
return null
}
return new ColorFile(code, filePath)
}, [code, filePath, syntaxHighlightingDisabled])
}, [code, filePath])
Comment on lines 37 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve the env-based disable in the fallback path.

expectColorFile() already returns null when CLAUDE_CODE_SYNTAX_HIGHLIGHT disables syntax highlighting in src/components/StructuredDiff/colorDiff.ts, Lines 18-31. Falling back with skipColoring={false} means this path still tells the fallback renderer to colorize, so the env switch no longer fully disables highlighting and now disagrees with the “Syntax highlighting unavailable” status in ThemePicker.

💡 Minimal fix
-import { expectColorFile } from './StructuredDiff/colorDiff.js'
+import {
+  expectColorFile,
+  getColorModuleUnavailableReason,
+} from './StructuredDiff/colorDiff.js'
   const [theme] = useTheme()
+  const syntaxHighlightingUnavailable =
+    getColorModuleUnavailableReason() !== null
 
   const colorFile = useMemo(() => {
     const ColorFile = expectColorFile()
     if (!ColorFile) {
       return null
@@
         <HighlightedCodeFallback
           code={code}
           filePath={filePath}
           dim={dim}
-          skipColoring={false}
+          skipColoring={syntaxHighlightingUnavailable}
         />

Also applies to: 88-93

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/HighlightedCode.tsx` around lines 37 - 43, When
expectColorFile() returns null due to the CLAUDE_CODE_SYNTAX_HIGHLIGHT env flag,
we must propagate that disable to the fallback renderer instead of forcing
skipColoring={false}; update the render paths that use the colorFile result
(references: colorFile variable, expectColorFile(), and the fallback prop
skipColoring) so that when ColorFile is null you pass skipColoring={true} (or
otherwise base skipColoring on !!colorFile), and apply the same change to the
other occurrence noted (lines 88-93) to keep the env-based disable consistent.


useEffect(() => {
if (!width && ref.current) {
Expand All @@ -69,7 +62,7 @@ export const HighlightedCode = memo(function HighlightedCode({
// line number (max_digits = lineCount.toString().length) + space. No marker
// column like the diff path. Wrap in <NoSelect> so fullscreen selection
// yields clean code without line numbers. Only split in fullscreen mode
// (~ DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native
// (~4x DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native
// selection where noSelect is meaningless.
const gutterWidth = useMemo(() => {
if (!isFullscreenEnvEnabled()) return 0
Expand All @@ -96,7 +89,7 @@ export const HighlightedCode = memo(function HighlightedCode({
code={code}
filePath={filePath}
dim={dim}
skipColoring={syntaxHighlightingDisabled}
skipColoring={false}
/>
)}
</Box>
Expand Down
33 changes: 0 additions & 33 deletions src/components/Spinner/GlimmerMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,13 @@ type Props = {
stalledIntensity?: number
}

const ERROR_RED = { r: 171, g: 43, b: 63 }

export function GlimmerMessage({
message,
mode,
messageColor,
glimmerIndex,
flashOpacity,
shimmerColor,
stalledIntensity = 0,
}: Props): React.ReactNode {
const [themeName] = useTheme()
const theme = getTheme(themeName)
Expand All @@ -43,36 +40,6 @@ export function GlimmerMessage({

if (!message) return null

// When stalled, show text that smoothly transitions to red
if (stalledIntensity > 0) {
const baseColorStr = theme[messageColor]
const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null

if (baseRGB) {
const interpolated = interpolateColor(
baseRGB,
ERROR_RED,
stalledIntensity,
)
const color = toRGBColor(interpolated)
return (
<>
<Text color={color}>{message}</Text>
<Text color={color}> </Text>
</>
)
}

// Fallback for ANSI themes: use messageColor until fully stalled, then error
const color = stalledIntensity > 0.5 ? 'error' : messageColor
return (
<>
<Text color={color}>{message}</Text>
<Text color={color}> </Text>
</>
)
}

// tool-use mode: all chars flash with the same opacity, so render as a
// single <Text> instead of N individual FlashingChar components.
if (mode === 'tool-use') {
Expand Down
2 changes: 0 additions & 2 deletions src/components/Spinner/SpinnerAnimationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,6 @@ export function SpinnerAnimationRow({
<SpinnerGlyph
frame={frame}
messageColor={messageColor}
stalledIntensity={overrideColor ? 0 : stalledIntensity}
reducedMotion={reducedMotion}
time={time}
/>
Expand All @@ -345,7 +344,6 @@ export function SpinnerAnimationRow({
glimmerIndex={glimmerIndex}
flashOpacity={flashOpacity}
shimmerColor={shimmerColor}
stalledIntensity={overrideColor ? 0 : stalledIntensity}
/>
{status}
</Box>
Expand Down
43 changes: 3 additions & 40 deletions src/components/Spinner/SpinnerGlyph.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import * as React from 'react'
import { Box, Text, useTheme } from '../../ink.js'
import { getTheme, type Theme } from '../../utils/theme.js'
import {
getDefaultCharacters,
interpolateColor,
parseRGB,
toRGBColor,
} from './utils.js'
import { Box, Text } from '../../ink.js'
import type { Theme } from '../../utils/theme.js'
import { getDefaultCharacters } from './utils.js'

const DEFAULT_CHARACTERS = getDefaultCharacters()

Expand All @@ -17,7 +12,6 @@ const SPINNER_FRAMES = [

const REDUCED_MOTION_DOT = '●'
const REDUCED_MOTION_CYCLE_MS = 2000 // 2-second cycle: 1s visible, 1s dim
const ERROR_RED = { r: 171, g: 43, b: 63 }

type Props = {
frame: number
Expand All @@ -30,13 +24,9 @@ type Props = {
export function SpinnerGlyph({
frame,
messageColor,
stalledIntensity = 0,
reducedMotion = false,
time = 0,
}: Props): React.ReactNode {
const [themeName] = useTheme()
const theme = getTheme(themeName)

// Reduced motion: slowly flashing orange dot
if (reducedMotion) {
const isDim = Math.floor(time / (REDUCED_MOTION_CYCLE_MS / 2)) % 2 === 1
Expand All @@ -51,33 +41,6 @@ export function SpinnerGlyph({

const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]

// Smoothly interpolate from current color to red when stalled
if (stalledIntensity > 0) {
const baseColorStr = theme[messageColor]
const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null

if (baseRGB) {
const interpolated = interpolateColor(
baseRGB,
ERROR_RED,
stalledIntensity,
)
return (
<Box flexWrap="wrap" height={1} width={2}>
<Text color={toRGBColor(interpolated)}>{spinnerChar}</Text>
</Box>
)
}

// Fallback for ANSI themes
const color = stalledIntensity > 0.5 ? 'error' : messageColor
return (
<Box flexWrap="wrap" height={1} width={2}>
<Text color={color}>{spinnerChar}</Text>
</Box>
)
}

return (
<Box flexWrap="wrap" height={1} width={2}>
<Text color={messageColor}>{spinnerChar}</Text>
Expand Down
Loading