From 027d36b381c5a1fd2ade9d8e64351441b5980907 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:40:32 +0800 Subject: [PATCH 1/2] feat: Customize TUI with brand colors and refined visual design Comprehensive theme and UI refinements: Theme Colors: - Replace Claude orange brand color with blue (rgb(88,190,255)) - Add BRAND_COLOR, BRAND_COLOR_LIGHT, BRAND_RED, BRAND_GREEN constants - Update all 6 theme variants (light, dark, light-ansi, dark-ansi, light-daltonized, dark-daltonized) with consistent brand palette - Change permission/suggestion/remember colors to cyan-blue (rgb(131,210,238)) - Set dark theme userMessageBackground to #0f0f0f for better contrast - Update diff colors: burgundy red (rgb(162,0,67)) and forest green Syntax Highlighting: - Add new Royal Gold Dark theme with gold/blue palette and lower saturation - Replace Monokai Extended as default dark syntax theme - Fine-tune scope colors for refined code appearance on black backgrounds UI Behavior Changes: - Remove spinner stalled-to-red warning animation for cleaner UX - Remove syntax highlighting toggle (Ctrl+T) from ThemePicker - Remove top padding from FullscreenLayout ScrollBox - Set inline code color to amber gold (#FEC84A) in dark themes Keybindings: - Remove 'theme:toggleSyntaxHighlighting' action from schema - Remove Ctrl+T binding from ThemePicker context Co-Authored-By: Claude Sonnet 4.6 --- packages/color-diff-napi/src/index.ts | 89 +++++++++---- src/components/FullscreenLayout.tsx | 2 +- src/components/HighlightedCode.tsx | 13 +- src/components/Spinner/GlimmerMessage.tsx | 33 ----- .../Spinner/SpinnerAnimationRow.tsx | 2 - src/components/Spinner/SpinnerGlyph.tsx | 43 +------ src/components/ThemePicker.tsx | 41 +----- src/keybindings/defaultBindings.ts | 1 - src/keybindings/schema.ts | 1 - src/utils/markdown.ts | 4 +- src/utils/theme.ts | 119 +++++++++--------- 11 files changed, 142 insertions(+), 206 deletions(-) diff --git a/packages/color-diff-napi/src/index.ts b/packages/color-diff-napi/src/index.ts index afaf924eae..65a93001ca 100644 --- a/packages/color-diff-napi/src/index.ts +++ b/packages/color-diff-napi/src/index.ts @@ -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' } @@ -221,6 +221,35 @@ const MONOKAI_SCOPES: Record = { 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 = { + 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 = { keyword: rgb(167, 29, 93), @@ -286,6 +315,18 @@ const ANSI_SCOPES: Record = { 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') @@ -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, diff --git a/src/components/FullscreenLayout.tsx b/src/components/FullscreenLayout.tsx index 8502e46de2..2c8a277a9a 100644 --- a/src/components/FullscreenLayout.tsx +++ b/src/components/FullscreenLayout.tsx @@ -398,7 +398,7 @@ export function FullscreenLayout({ ref={scrollRef} flexGrow={1} flexDirection="column" - paddingTop={padCollapsed ? 0 : 1} + paddingTop={0} stickyScroll > diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx index 47f7271bca..6d1f02de81 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -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, @@ -34,20 +33,14 @@ export const HighlightedCode = memo(function HighlightedCode({ const ref = useRef(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]) useEffect(() => { if (!width && ref.current) { @@ -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 so fullscreen selection // yields clean code without line numbers. Only split in fullscreen mode - // (~4× 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 @@ -96,7 +89,7 @@ export const HighlightedCode = memo(function HighlightedCode({ code={code} filePath={filePath} dim={dim} - skipColoring={syntaxHighlightingDisabled} + skipColoring={false} /> )} diff --git a/src/components/Spinner/GlimmerMessage.tsx b/src/components/Spinner/GlimmerMessage.tsx index 3e488f9a14..2b8cc3c9b3 100644 --- a/src/components/Spinner/GlimmerMessage.tsx +++ b/src/components/Spinner/GlimmerMessage.tsx @@ -16,8 +16,6 @@ type Props = { stalledIntensity?: number } -const ERROR_RED = { r: 171, g: 43, b: 63 } - export function GlimmerMessage({ message, mode, @@ -25,7 +23,6 @@ export function GlimmerMessage({ glimmerIndex, flashOpacity, shimmerColor, - stalledIntensity = 0, }: Props): React.ReactNode { const [themeName] = useTheme() const theme = getTheme(themeName) @@ -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 ( - <> - {message} - - - ) - } - - // Fallback for ANSI themes: use messageColor until fully stalled, then error - const color = stalledIntensity > 0.5 ? 'error' : messageColor - return ( - <> - {message} - - - ) - } - // tool-use mode: all chars flash with the same opacity, so render as a // single instead of N individual FlashingChar components. if (mode === 'tool-use') { diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index 93b2fc64a5..da7f98d81d 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -334,7 +334,6 @@ export function SpinnerAnimationRow({ @@ -345,7 +344,6 @@ export function SpinnerAnimationRow({ glimmerIndex={glimmerIndex} flashOpacity={flashOpacity} shimmerColor={shimmerColor} - stalledIntensity={overrideColor ? 0 : stalledIntensity} /> {status} diff --git a/src/components/Spinner/SpinnerGlyph.tsx b/src/components/Spinner/SpinnerGlyph.tsx index 242d05971d..d389cf75c5 100644 --- a/src/components/Spinner/SpinnerGlyph.tsx +++ b/src/components/Spinner/SpinnerGlyph.tsx @@ -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() @@ -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 @@ -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 @@ -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 ( - - {spinnerChar} - - ) - } - - // Fallback for ANSI themes - const color = stalledIntensity > 0.5 ? 'error' : messageColor - return ( - - {spinnerChar} - - ) - } - return ( {spinnerChar} diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index b14bcfd2c8..1cc0bb85fb 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -10,11 +10,7 @@ import { useThemeSetting, } from '../ink.js' import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { useAppState, useSetAppState } from '../state/AppState.js' import { gracefulShutdown } from '../utils/gracefulShutdown.js' -import { updateSettingsForSource } from '../utils/settings/settings.js' import type { ThemeSetting } from '../utils/theme.js' import { Select } from './CustomSelect/index.js' import { Byline } from './design-system/Byline.js' @@ -53,35 +49,10 @@ export function ThemePicker({ const syntaxTheme = colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme() - const syntaxHighlightingDisabled = - useAppState(s => s.settings.syntaxHighlightingDisabled) ?? false - const setAppState = useSetAppState() // Register ThemePicker context so its keybindings take precedence over Global useRegisterKeybindingContext('ThemePicker') - const syntaxToggleShortcut = useShortcutDisplay( - 'theme:toggleSyntaxHighlighting', - 'ThemePicker', - 'ctrl+t', - ) - - useKeybinding( - 'theme:toggleSyntaxHighlighting', - () => { - if (colorModuleUnavailableReason === null) { - const newValue = !syntaxHighlightingDisabled - updateSettingsForSource('userSettings', { - syntaxHighlightingDisabled: newValue, - }) - setAppState(prev => ({ - ...prev, - settings: { ...prev.settings, syntaxHighlightingDisabled: newValue }, - })) - } - }, - { context: 'ThemePicker' }, - ) // Always call the hook to follow React rules, but conditionally assign the exit handler const exitState = useExitOnCtrlCDWithKeybindings( skipExitHandling ? () => {} : undefined, @@ -115,7 +86,7 @@ export function ThemePicker({ {showIntroText ? ( - Let's get started. + Let's get started. ) : ( Theme @@ -184,12 +155,10 @@ export function ThemePicker({ {' '} {colorModuleUnavailableReason === 'env' - ? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` - : syntaxHighlightingDisabled - ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` - : syntaxTheme - ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ''} (${syntaxToggleShortcut} to disable)` - : `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`} + ? `Syntax highlighting unavailable (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` + : syntaxTheme + ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ''}` + : 'Syntax highlighting enabled'} diff --git a/src/keybindings/defaultBindings.ts b/src/keybindings/defaultBindings.ts index 8629809d95..22ff9c363a 100644 --- a/src/keybindings/defaultBindings.ts +++ b/src/keybindings/defaultBindings.ts @@ -189,7 +189,6 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [ { context: 'ThemePicker', bindings: { - 'ctrl+t': 'theme:toggleSyntaxHighlighting', }, }, { diff --git a/src/keybindings/schema.ts b/src/keybindings/schema.ts index 3e61d63a51..66fad71b6e 100644 --- a/src/keybindings/schema.ts +++ b/src/keybindings/schema.ts @@ -120,7 +120,6 @@ export const KEYBINDING_ACTIONS = [ // Task/agent actions 'task:background', // Theme picker actions - 'theme:toggleSyntaxHighlighting', // Help menu actions 'help:dismiss', // Attachment navigation (select dialog image attachments) diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index b2c673e6d5..f13681e9ce 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -87,7 +87,9 @@ export function formatToken( } case 'codespan': { // inline code - return color('permission', theme)(token.text) + return color(theme?.startsWith('dark') ? '#FEC84A' : 'permission', theme)( + token.text, + ) } case 'em': return chalk.italic( diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 9491dca652..af18c4814d 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -1,6 +1,11 @@ import chalk, { Chalk } from 'chalk' import { env } from './env.js' +const BRAND_COLOR = 'rgb(88,190,255)' +const BRAND_COLOR_LIGHT = 'rgb(135,210,255)' // Lighter for shimmer +const BRAND_RED = 'rgb(162,0,67)' +const BRAND_GREEN = 'rgb(34,139,34)' + export type Theme = { autoAccept: string bashBorder: string @@ -115,8 +120,8 @@ export type ThemeSetting = (typeof THEME_SETTINGS)[number] const lightTheme: Theme = { autoAccept: 'rgb(135,0,255)', // Electric violet bashBorder: 'rgb(255,0,135)', // Vibrant pink - claude: 'rgb(215,119,87)', // Claude orange - claudeShimmer: 'rgb(245,149,117)', // Lighter claude orange for shimmer effect + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(87,105,247)', // Medium blue for system spinner claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(117,135,255)', // Lighter blue for system spinner shimmer permission: 'rgb(87,105,247)', // Medium blue @@ -133,17 +138,17 @@ const lightTheme: Theme = { suggestion: 'rgb(87,105,247)', // Medium blue remember: 'rgb(0,0,255)', // Blue background: 'rgb(0,153,153)', // Cyan - success: 'rgb(44,122,57)', // Green - error: 'rgb(171,43,63)', // Red + success: BRAND_GREEN, + error: BRAND_RED, warning: 'rgb(150,108,30)', // Amber merged: 'rgb(135,0,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(200,158,80)', // Lighter amber for shimmer effect - diffAdded: 'rgb(105,219,124)', // Light green - diffRemoved: 'rgb(255,168,180)', // Light red - diffAddedDimmed: 'rgb(199,225,203)', // Very light green - diffRemovedDimmed: 'rgb(253,210,216)', // Very light red - diffAddedWord: 'rgb(47,157,68)', // Medium green - diffRemovedWord: 'rgb(209,69,75)', // Medium red + diffAdded: 'rgb(153,204,255)', // Light blue instead of green + diffRemoved: 'rgb(255,204,204)', // Light red + diffAddedDimmed: 'rgb(209,231,253)', // Very light blue + diffRemovedDimmed: 'rgb(255,233,233)', // Very light red + diffAddedWord: 'rgb(51,102,204)', // Medium blue (less intense than deep blue) + diffRemovedWord: 'rgb(153,51,51)', // Softer red (less intense than deep red) // Agent colors red_FOR_SUBAGENTS_ONLY: 'rgb(220,38,38)', // Red 600 blue_FOR_SUBAGENTS_ONLY: 'rgb(37,99,235)', // Blue 600 @@ -158,7 +163,7 @@ const lightTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(240, 240, 240)', // Slightly darker grey for optimal contrast userMessageBackgroundHover: 'rgb(252, 252, 252)', // ≥250 to quantize distinct from base at 256-color level @@ -173,7 +178,7 @@ const lightTheme: Theme = { fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer // Brief/assistant mode briefLabelYou: 'rgb(37,99,235)', // Blue - briefLabelClaude: 'rgb(215,119,87)', // Brand orange + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -254,7 +259,7 @@ const lightAnsiTheme: Theme = { fastMode: 'ansi:red', fastModeShimmer: 'ansi:redBright', briefLabelYou: 'ansi:blue', - briefLabelClaude: 'ansi:redBright', + briefLabelClaude: BRAND_COLOR, rainbow_red: 'ansi:red', rainbow_orange: 'ansi:redBright', rainbow_yellow: 'ansi:yellow', @@ -335,7 +340,7 @@ const darkAnsiTheme: Theme = { fastMode: 'ansi:redBright', fastModeShimmer: 'ansi:redBright', briefLabelYou: 'ansi:blueBright', - briefLabelClaude: 'ansi:redBright', + briefLabelClaude: BRAND_COLOR, rainbow_red: 'ansi:red', rainbow_orange: 'ansi:redBright', rainbow_yellow: 'ansi:yellow', @@ -359,8 +364,8 @@ const darkAnsiTheme: Theme = { const lightDaltonizedTheme: Theme = { autoAccept: 'rgb(135,0,255)', // Electric violet bashBorder: 'rgb(0,102,204)', // Blue instead of pink - claude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia - claudeShimmer: 'rgb(255,183,101)', // Lighter orange for shimmer effect + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(51,102,255)', // Bright blue for system spinner claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(101,152,255)', // Lighter bright blue for system spinner shimmer permission: 'rgb(51,102,255)', // Bright blue @@ -382,12 +387,12 @@ const lightDaltonizedTheme: Theme = { warning: 'rgb(255,153,0)', // Orange adjusted for deuteranopia merged: 'rgb(135,0,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(255,183,50)', // Lighter orange for shimmer - diffAdded: 'rgb(153,204,255)', // Light blue instead of green - diffRemoved: 'rgb(255,204,204)', // Light red - diffAddedDimmed: 'rgb(209,231,253)', // Very light blue - diffRemovedDimmed: 'rgb(255,233,233)', // Very light red - diffAddedWord: 'rgb(51,102,204)', // Medium blue (less intense than deep blue) - diffRemovedWord: 'rgb(153,51,51)', // Softer red (less intense than deep red) + diffAdded: 'rgb(170,214,170)', + diffRemoved: 'rgb(228,170,196)', + diffAddedDimmed: 'rgb(220,238,220)', + diffRemovedDimmed: 'rgb(242,220,230)', + diffAddedWord: BRAND_GREEN, + diffRemovedWord: BRAND_RED, // Agent colors (daltonism-friendly) red_FOR_SUBAGENTS_ONLY: 'rgb(204,0,0)', // Pure red blue_FOR_SUBAGENTS_ONLY: 'rgb(0,102,204)', // Pure blue @@ -402,7 +407,7 @@ const lightDaltonizedTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(220, 220, 220)', // Slightly darker grey for optimal contrast userMessageBackgroundHover: 'rgb(232, 232, 232)', // ≥230 to quantize distinct from base at 256-color level @@ -416,7 +421,7 @@ const lightDaltonizedTheme: Theme = { fastMode: 'rgb(255,106,0)', // Electric orange (color-blind safe) fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer briefLabelYou: 'rgb(37,99,235)', // Blue - briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude) + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -440,12 +445,12 @@ const lightDaltonizedTheme: Theme = { const darkTheme: Theme = { autoAccept: 'rgb(175,135,255)', // Electric violet bashBorder: 'rgb(253,93,177)', // Bright pink - claude: 'rgb(215,119,87)', // Claude orange - claudeShimmer: 'rgb(235,159,127)', // Lighter claude orange for shimmer effect - claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(147,165,255)', // Blue for system spinner - claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(177,195,255)', // Lighter blue for system spinner shimmer - permission: 'rgb(177,185,249)', // Light blue-purple - permissionShimmer: 'rgb(207,215,255)', // Lighter blue-purple for shimmer + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, + claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(131,210,238)', // Light cyan-blue + claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(177,231,245)', // Lighter cyan-blue for shimmer + permission: 'rgb(131,210,238)', // Light cyan-blue + permissionShimmer: 'rgb(177,231,245)', // Lighter cyan-blue for shimmer planMode: 'rgb(72,150,140)', // Muted sage green ide: 'rgb(71,130,200)', // Muted blue promptBorder: 'rgb(136,136,136)', // Medium gray @@ -455,20 +460,20 @@ const darkTheme: Theme = { inactive: 'rgb(153,153,153)', // Light gray inactiveShimmer: 'rgb(193,193,193)', // Lighter gray for shimmer effect subtle: 'rgb(80,80,80)', // Dark gray - suggestion: 'rgb(177,185,249)', // Light blue-purple - remember: 'rgb(177,185,249)', // Light blue-purple + suggestion: 'rgb(131,210,238)', // Light cyan-blue + remember: 'rgb(131,210,238)', // Light cyan-blue background: 'rgb(0,204,204)', // Bright cyan - success: 'rgb(78,186,101)', // Bright green - error: 'rgb(255,107,128)', // Bright red + success: BRAND_GREEN, + error: BRAND_RED, warning: 'rgb(255,193,7)', // Bright amber merged: 'rgb(175,135,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(255,223,57)', // Lighter amber for shimmer - diffAdded: 'rgb(34,92,43)', // Dark green - diffRemoved: 'rgb(122,41,54)', // Dark red - diffAddedDimmed: 'rgb(71,88,74)', // Very dark green - diffRemovedDimmed: 'rgb(105,72,77)', // Very dark red - diffAddedWord: 'rgb(56,166,96)', // Medium green - diffRemovedWord: 'rgb(179,89,107)', // Softer red (less intense than bright red) + diffAdded: 'rgb(20,54,20)', + diffRemoved: 'rgb(74,0,31)', + diffAddedDimmed: 'rgb(38,48,38)', + diffRemovedDimmed: 'rgb(57,38,46)', + diffAddedWord: BRAND_GREEN, + diffRemovedWord: BRAND_RED, // Agent colors red_FOR_SUBAGENTS_ONLY: 'rgb(220,38,38)', // Red 600 blue_FOR_SUBAGENTS_ONLY: 'rgb(37,99,235)', // Blue 600 @@ -483,21 +488,21 @@ const darkTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', - userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast - userMessageBackgroundHover: 'rgb(70, 70, 70)', + userMessageBackground: '#0f0f0f', + userMessageBackgroundHover: '#191919', messageActionsBackground: 'rgb(44, 50, 62)', // cool gray, slight blue selectionBg: 'rgb(38, 79, 120)', // classic dark-mode selection blue (VS Code dark default); light fgs stay readable bashMessageBackgroundColor: 'rgb(65, 60, 65)', memoryBackgroundColor: 'rgb(55, 65, 70)', - rate_limit_fill: 'rgb(177,185,249)', // Light blue-purple + rate_limit_fill: 'rgb(131,210,238)', // Light cyan-blue rate_limit_empty: 'rgb(80,83,112)', // Medium blue-purple fastMode: 'rgb(255,120,20)', // Electric orange for dark bg fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer briefLabelYou: 'rgb(122,180,232)', // Light blue - briefLabelClaude: 'rgb(215,119,87)', // Brand orange + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -521,8 +526,8 @@ const darkTheme: Theme = { const darkDaltonizedTheme: Theme = { autoAccept: 'rgb(175,135,255)', // Electric violet bashBorder: 'rgb(51,153,255)', // Bright blue - claude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia - claudeShimmer: 'rgb(255,183,101)', // Lighter orange for shimmer effect + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(153,204,255)', // Light blue for system spinner claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(183,224,255)', // Lighter blue for system spinner shimmer permission: 'rgb(153,204,255)', // Light blue @@ -539,17 +544,17 @@ const darkDaltonizedTheme: Theme = { suggestion: 'rgb(153,204,255)', // Light blue remember: 'rgb(153,204,255)', // Light blue background: 'rgb(0,204,204)', // Bright cyan (color-blind friendly) - success: 'rgb(51,153,255)', // Blue instead of green - error: 'rgb(255,102,102)', // Bright red + success: 'rgb(0,153,204)', // Cyan-blue instead of green for deuteranopia + error: 'rgb(255,102,102)', // Bright red for better distinction warning: 'rgb(255,204,0)', // Yellow-orange for deuteranopia merged: 'rgb(175,135,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(255,234,50)', // Lighter yellow-orange for shimmer - diffAdded: 'rgb(0,68,102)', // Dark blue - diffRemoved: 'rgb(102,0,0)', // Dark red - diffAddedDimmed: 'rgb(62,81,91)', // Dimmed blue - diffRemovedDimmed: 'rgb(62,44,44)', // Dimmed red - diffAddedWord: 'rgb(0,119,179)', // Medium blue - diffRemovedWord: 'rgb(179,0,0)', // Medium red + diffAdded: 'rgb(0,27,41)', // Dark blue instead of green + diffRemoved: 'rgb(122,41,54)', // Dark red + diffAddedDimmed: 'rgb(51,68,71)', // Very dark blue + diffRemovedDimmed: 'rgb(105,72,77)', // Very dark red + diffAddedWord: 'rgb(81,160,200)', // Medium blue + diffRemovedWord: 'rgb(179,89,107)', // Softer red // Agent colors (daltonism-friendly, dark mode) red_FOR_SUBAGENTS_ONLY: 'rgb(255,102,102)', // Bright red blue_FOR_SUBAGENTS_ONLY: 'rgb(102,178,255)', // Bright blue @@ -564,7 +569,7 @@ const darkDaltonizedTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast userMessageBackgroundHover: 'rgb(70, 70, 70)', @@ -578,7 +583,7 @@ const darkDaltonizedTheme: Theme = { fastMode: 'rgb(255,120,20)', // Electric orange for dark bg (color-blind safe) fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer briefLabelYou: 'rgb(122,180,232)', // Light blue - briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude) + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', From 3214e5856acca5237213bbb97a33032c7ee4517a Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:07:01 +0800 Subject: [PATCH 2/2] feat: Enhance /buddy command with full companion management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete text-based buddy commands: - /buddy pet - Trigger heart animation + companion reaction - /buddy rehatch - Re-roll a new companion with stats - /buddy mute - Hide companion - /buddy unmute - Show companion Features: - 20-char stat bars (████████░░) for DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK - Auto-hatch on first /buddy call (no need for explicit hatch) - Display sprite, rarity, eye, hat, personality in text format - Show available commands after companion info Also enable BUDDY feature in production builds by default. Co-Authored-By: Claude Sonnet 4.6 --- build.ts | 2 +- src/commands/buddy/buddy.ts | 114 +++++++++++++++++++++++++++++------- src/commands/buddy/index.ts | 4 +- 3 files changed, 96 insertions(+), 24 deletions(-) diff --git a/build.ts b/build.ts index 203fc23d97..22a2a20c8f 100644 --- a/build.ts +++ b/build.ts @@ -10,7 +10,7 @@ rmSync(outdir, { recursive: true, force: true }); // Default features that match the official CLI build. // Additional features can be enabled via FEATURE_=1 env vars. -const DEFAULT_BUILD_FEATURES = ["AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE"]; +const DEFAULT_BUILD_FEATURES = ["AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE", "BUDDY"]; // Collect FEATURE_* env vars → Bun.build features const envFeatures = Object.keys(process.env) diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts index 8d14ab4e6a..5460f75d33 100644 --- a/src/commands/buddy/buddy.ts +++ b/src/commands/buddy/buddy.ts @@ -1,4 +1,3 @@ -import React from 'react' import { getCompanion, rollWithSeed, @@ -6,7 +5,6 @@ import { } from '../../buddy/companion.js' import { type StoredCompanion, RARITY_STARS } from '../../buddy/types.js' import { renderSprite } from '../../buddy/sprites.js' -import { CompanionCard } from '../../buddy/CompanionCard.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { triggerCompanionReaction } from '../../buddy/companionReact.js' import type { ToolUseContext } from '../../Tool.js' @@ -67,6 +65,22 @@ function speciesLabel(species: string): string { return species.charAt(0).toUpperCase() + species.slice(1) } +function renderStats(stats: Record): string { + const lines = [ + 'DEBUGGING', + 'PATIENCE', + 'CHAOS', + 'WISDOM', + 'SNARK', + ].map(name => { + const val = stats[name] ?? 0 + const filled = Math.round(val / 5) + const bar = '█'.repeat(filled) + '░'.repeat(20 - filled) + return ` ${name.padEnd(10)} ${bar} ${val}` + }) + return lines.join('\n') +} + export async function call( onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext, @@ -75,20 +89,61 @@ export async function call( const sub = args?.trim().toLowerCase() ?? '' const setState = context.setAppState - // ── /buddy off — mute companion ── - if (sub === 'off') { + // ── /buddy mute — mute companion ── + if (sub === 'mute') { saveGlobalConfig(cfg => ({ ...cfg, companionMuted: true })) onDone('companion muted', { display: 'system' }) return null } - // ── /buddy on — unmute companion ── - if (sub === 'on') { + // ── /buddy unmute — unmute companion ── + if (sub === 'unmute') { saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false })) onDone('companion unmuted', { display: 'system' }) return null } + // ── /buddy rehatch — re-roll a new companion (replaces existing) ── + if (sub === 'rehatch') { + const seed = generateSeed() + const r = rollWithSeed(seed) + const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy' + const personality = + SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.' + + const stored: StoredCompanion = { + name, + personality, + seed, + hatchedAt: Date.now(), + } + + saveGlobalConfig(cfg => ({ ...cfg, companion: stored })) + + const stars = RARITY_STARS[r.bones.rarity] + const sprite = renderSprite(r.bones, 0) + const shiny = r.bones.shiny ? ' ✨ Shiny!' : '' + + const lines = [ + '🎉 A new companion appeared!', + '', + ...sprite, + '', + ` ${name} the ${speciesLabel(r.bones.species)}${shiny}`, + ` Rarity: ${stars} (${r.bones.rarity})`, + ` Eye: ${r.bones.eye} Hat: ${r.bones.hat}`, + '', + ` "${personality}"`, + '', + ' Stats:', + renderStats(r.bones.stats), + '', + ' Your old companion has been replaced!', + ] + onDone(lines.join('\n'), { display: 'system' }) + return null + } + // ── /buddy pet — trigger heart animation + auto unmute ── if (sub === 'pet') { const companion = getCompanion() @@ -123,16 +178,29 @@ export async function call( } if (companion) { - // Return JSX card — matches official vc8 component - const lastReaction = context.getAppState?.()?.companionReaction - return React.createElement(CompanionCard, { - companion, - lastReaction, - onDone, - }) + // Show text-based companion info with 20-char stats + const stars = RARITY_STARS[companion.rarity] + const sprite = renderSprite(companion, 0) + const shiny = companion.shiny ? ' ✨ Shiny!' : '' + + const lines = [ + ...sprite, + '', + ` ${companion.name} the ${speciesLabel(companion.species)}${shiny}`, + ` Rarity: ${stars} (${companion.rarity})`, + ` Eye: ${companion.eye} Hat: ${companion.hat}`, + companion.personality ? `\n "${companion.personality}"` : '', + '', + ' Stats:', + renderStats(companion.stats), + '', + ' Commands: /buddy pet /buddy mute /buddy unmute /buddy rehatch', + ] + onDone(lines.join('\n'), { display: 'system' }) + return null } - // ── No companion → hatch ── + // ── No companion → auto hatch ── const seed = generateSeed() const r = rollWithSeed(seed) const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy' @@ -150,19 +218,23 @@ export async function call( const stars = RARITY_STARS[r.bones.rarity] const sprite = renderSprite(r.bones, 0) - const shiny = r.bones.shiny ? ' \u2728 Shiny!' : '' + const shiny = r.bones.shiny ? ' ✨ Shiny!' : '' const lines = [ - 'A wild companion appeared!', + '🎉 A wild companion appeared!', '', ...sprite, '', - `${name} the ${speciesLabel(r.bones.species)}${shiny}`, - `Rarity: ${stars} (${r.bones.rarity})`, - `"${personality}"`, + ` ${name} the ${speciesLabel(r.bones.species)}${shiny}`, + ` Rarity: ${stars} (${r.bones.rarity})`, + ` Eye: ${r.bones.eye} Hat: ${r.bones.hat}`, + '', + ` "${personality}"`, + '', + ' Stats:', + renderStats(r.bones.stats), '', - 'Your companion will now appear beside your input box!', - 'Say its name to get its take \u00b7 /buddy pet \u00b7 /buddy off', + ' Your companion will now appear beside your input box!', ] onDone(lines.join('\n'), { display: 'system' }) return null diff --git a/src/commands/buddy/index.ts b/src/commands/buddy/index.ts index 8df6830281..839bf41206 100644 --- a/src/commands/buddy/index.ts +++ b/src/commands/buddy/index.ts @@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js' const buddy = { type: 'local-jsx', name: 'buddy', - description: 'Hatch a coding companion · pet, off', - argumentHint: '[pet|off]', + description: 'Coding companion · pet, rehatch, mute, unmute', + argumentHint: '[pet|rehatch|mute|unmute]', immediate: true, get isHidden() { return !isBuddyLive()