mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
Feat/ultracode blue spinner (#2096)
* feat(ultracode): add blue/cyan spinner and effort visual treatment - Add EFFORT_ULTRACODE (◆) figure for effort display surfaces - Add ultracode/ultracodeShimmer theme colors across all 6 theme variants - Wire ultracode case into effortLevelToSymbol() for icon rendering - Use blue-cyan RGB shimmer for "thinking" text when ultracode is active - Set spinner color override in REPL when displayed effort is ultracode * feat(ultracode): tint prompt border and unify shimmer to theme tokens Add a persistent cyan-blue prompt border whenever ultracode is the active effort, reacting immediately to /effort and ranking below bash/teammate overrides. Derive the spinner thinking-shimmer from the ultracode/ ultracodeShimmer theme tokens (with an ANSI/daltonized fallback) instead of a divergent hardcoded cyan, so border, spinner, and shimmer share one source of truth. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * test(spinner): cover ultracode shimmer color selection and ANSI fallback Extracts the thinking-shimmer color computation into an exported getThinkingShimmerColor helper (renderToString strips ANSI color, so the selection logic is only observable through a direct call) and adds focused tests for ultracode rgb() token interpolation, the ansi:* fallback endpoints, and the non-ultracode gray interpolation. Addresses CodeRabbit review on #2096. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> --------- Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
co-authored by
OpenClaude
parent
63fda83d55
commit
5844d1fe8a
@@ -3,6 +3,7 @@ import {
|
||||
EFFORT_LOW,
|
||||
EFFORT_MAX,
|
||||
EFFORT_MEDIUM,
|
||||
EFFORT_ULTRACODE,
|
||||
} from '../constants/figures.js'
|
||||
import {
|
||||
type EffortLevel,
|
||||
@@ -34,6 +35,8 @@ export function effortLevelToSymbol(level: EffortLevel): string {
|
||||
return EFFORT_HIGH
|
||||
case 'max':
|
||||
return EFFORT_MAX
|
||||
case 'ultracode':
|
||||
return EFFORT_ULTRACODE
|
||||
default:
|
||||
// Defensive: level can originate from remote config. If an unknown
|
||||
// value slips through, render the high symbol rather than undefined.
|
||||
|
||||
@@ -133,6 +133,9 @@ type Props = {
|
||||
commands: Command[];
|
||||
agents: AgentDefinition[];
|
||||
isLoading: boolean;
|
||||
/** True when ultracode is the active effort. Tints the prompt border
|
||||
* cyan-blue as a persistent ambient indicator of the mode. */
|
||||
ultracodeActive?: boolean;
|
||||
verbose: boolean;
|
||||
messages: Message[];
|
||||
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
|
||||
@@ -203,6 +206,7 @@ function PromptInput({
|
||||
commands,
|
||||
agents,
|
||||
isLoading,
|
||||
ultracodeActive,
|
||||
verbose,
|
||||
messages,
|
||||
onAutoUpdaterResult,
|
||||
@@ -2268,6 +2272,13 @@ function PromptInput({
|
||||
if (teammateColorName && AGENT_COLORS.includes(teammateColorName as AgentColorName)) {
|
||||
return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName];
|
||||
}
|
||||
|
||||
// Ambient ultracode indicator: cyan-blue border whenever ultracode is the
|
||||
// active effort. Ranks below bash mode and teammate identity (explicit
|
||||
// contextual overrides) but above the default border.
|
||||
if (ultracodeActive) {
|
||||
return 'ultracode';
|
||||
}
|
||||
return 'promptBorder';
|
||||
};
|
||||
if (isExternalEditorActive) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import figures from 'figures'
|
||||
import { renderToString } from '../../utils/staticRender.js'
|
||||
import {
|
||||
getCurrentResponseTokenCount,
|
||||
getThinkingShimmerColor,
|
||||
SpinnerAnimationRow,
|
||||
type SpinnerAnimationRowProps,
|
||||
} from './SpinnerAnimationRow.js'
|
||||
@@ -1057,3 +1058,36 @@ describe('SpinnerAnimationRow', () => {
|
||||
expect(activeRows[0]).not.toContain(figures.arrowDown)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getThinkingShimmerColor', () => {
|
||||
// Mirrors the default palette's rgb() tokens (theme values are always
|
||||
// rgb() strings there, never hex).
|
||||
const rgbTheme = {
|
||||
ultracode: 'rgb(0,180,216)',
|
||||
ultracodeShimmer: 'rgb(77,215,255)',
|
||||
}
|
||||
// Mirrors ANSI/daltonized palettes, whose tokens parseRGB can't interpolate.
|
||||
const ansiTheme = {
|
||||
ultracode: 'ansi:cyanBright',
|
||||
ultracodeShimmer: 'ansi:cyanBright',
|
||||
}
|
||||
|
||||
it('interpolates ultracode shimmer between the theme rgb() tokens', () => {
|
||||
expect(getThinkingShimmerColor(rgbTheme, true, 0)).toBe('rgb(0,180,216)')
|
||||
expect(getThinkingShimmerColor(rgbTheme, true, 1)).toBe('rgb(77,215,255)')
|
||||
expect(getThinkingShimmerColor(rgbTheme, true, 0.5)).toBe('rgb(39,198,236)')
|
||||
})
|
||||
|
||||
it('falls back to the ultracode RGB constants for ansi:* theme tokens', () => {
|
||||
expect(getThinkingShimmerColor(ansiTheme, true, 0)).toBe('rgb(0,155,196)')
|
||||
expect(getThinkingShimmerColor(ansiTheme, true, 1)).toBe('rgb(44,200,237)')
|
||||
})
|
||||
|
||||
it('interpolates the gray thinking shimmer when ultracode is not active', () => {
|
||||
for (const theme of [rgbTheme, ansiTheme]) {
|
||||
expect(getThinkingShimmerColor(theme, false, 0)).toBe('rgb(153,153,153)')
|
||||
expect(getThinkingShimmerColor(theme, false, 1)).toBe('rgb(185,185,185)')
|
||||
expect(getThinkingShimmerColor(theme, false, 0.5)).toBe('rgb(169,169,169)')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,18 +3,18 @@ import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { stringWidth } from '../../ink/stringWidth.js';
|
||||
import { Box, Text, useAnimationFrame } from '../../ink.js';
|
||||
import { Box, Text, useAnimationFrame, useTheme } from '../../ink.js';
|
||||
import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js';
|
||||
import { formatDuration, formatNumber } from '../../utils/format.js';
|
||||
import { toInkColor } from '../../utils/ink.js';
|
||||
import type { Theme } from '../../utils/theme.js';
|
||||
import { getTheme, type Theme } from '../../utils/theme.js';
|
||||
import { Byline } from '../design-system/Byline.js';
|
||||
import FullWidthRow from '../design-system/FullWidthRow.js';
|
||||
import { GlimmerMessage } from './GlimmerMessage.js';
|
||||
import { SpinnerGlyph } from './SpinnerGlyph.js';
|
||||
import type { SpinnerMode } from './types.js';
|
||||
import { useStalledAnimation } from './useStalledAnimation.js';
|
||||
import { interpolateColor, toRGBColor } from './utils.js';
|
||||
import { interpolateColor, parseRGB, toRGBColor } from './utils.js';
|
||||
const SEP_WIDTH = stringWidth(' · ');
|
||||
const THINKING_BARE_WIDTH = stringWidth('thinking');
|
||||
// Show the elapsed-time counter after a short delay so long tool calls still
|
||||
@@ -34,9 +34,31 @@ const THINKING_INACTIVE_SHIMMER = {
|
||||
g: 185,
|
||||
b: 185
|
||||
};
|
||||
// Ultracode thinking shimmer (blue/cyan) — distinct from the gray thinking
|
||||
// shimmer. Endpoints are normally derived from the `ultracode`/`ultracodeShimmer`
|
||||
// theme tokens so the spinner, border, and shimmer share one source of truth.
|
||||
// These constants are the fallback for ANSI/daltonized themes where the token
|
||||
// is a named ANSI color that parseRGB can't interpolate.
|
||||
const ULTRACODE_INACTIVE_FALLBACK = {
|
||||
r: 0,
|
||||
g: 155,
|
||||
b: 196
|
||||
};
|
||||
const ULTRACODE_SHIMMER_FALLBACK = {
|
||||
r: 44,
|
||||
g: 200,
|
||||
b: 237
|
||||
};
|
||||
const THINKING_DELAY_MS = 3000;
|
||||
const THINKING_GLOW_PERIOD_S = 2;
|
||||
|
||||
// Exported for tests: renderToString strips ANSI color, so the shimmer color
|
||||
// selection (theme-token interpolation vs ANSI-theme fallback) is only
|
||||
// observable through this helper.
|
||||
export function getThinkingShimmerColor(theme: Pick<Theme, 'ultracode' | 'ultracodeShimmer'>, isUltracodeOverride: boolean, thinkingOpacity: number) {
|
||||
return isUltracodeOverride ? toRGBColor(interpolateColor(parseRGB(theme.ultracode) ?? ULTRACODE_INACTIVE_FALLBACK, parseRGB(theme.ultracodeShimmer) ?? ULTRACODE_SHIMMER_FALLBACK, thinkingOpacity)) : toRGBColor(interpolateColor(THINKING_INACTIVE, THINKING_INACTIVE_SHIMMER, thinkingOpacity));
|
||||
}
|
||||
|
||||
export function getCurrentResponseTokenCount(responseLength: number): number {
|
||||
return Math.round(responseLength / 4);
|
||||
}
|
||||
@@ -112,6 +134,8 @@ export function SpinnerAnimationRow({
|
||||
effortSuffix
|
||||
}: SpinnerAnimationRowProps): React.ReactNode {
|
||||
const [viewportRef, time] = useAnimationFrame(reducedMotion ? null : 50);
|
||||
const [themeName] = useTheme();
|
||||
const theme = getTheme(themeName);
|
||||
|
||||
// === Elapsed time (wall-clock, derived from refs each frame) ===
|
||||
const now = Date.now();
|
||||
@@ -607,7 +631,8 @@ export function SpinnerAnimationRow({
|
||||
// second useAnimationFrame(50) subscription.
|
||||
const thinkingElapsedSec = (time - THINKING_DELAY_MS) / 1000;
|
||||
const thinkingOpacity = time < THINKING_DELAY_MS ? 0 : (Math.sin(thinkingElapsedSec * Math.PI * 2 / THINKING_GLOW_PERIOD_S) + 1) / 2;
|
||||
const thinkingShimmerColor = toRGBColor(interpolateColor(THINKING_INACTIVE, THINKING_INACTIVE_SHIMMER, thinkingOpacity));
|
||||
const isUltracodeOverride = overrideColor === 'ultracode';
|
||||
const thinkingShimmerColor = getThinkingShimmerColor(theme, isUltracodeOverride, thinkingOpacity);
|
||||
|
||||
// === Build status parts ===
|
||||
// bareThinkingOnly nests parens on the thinking word (no outer status parens).
|
||||
|
||||
@@ -13,6 +13,7 @@ export const EFFORT_LOW = '○' // \u25cb - effort level: low
|
||||
export const EFFORT_MEDIUM = '◐' // \u25d0 - effort level: medium
|
||||
export const EFFORT_HIGH = '●' // \u25cf - effort level: high
|
||||
export const EFFORT_MAX = '◉' // \u25c9 - effort level: max (Opus 4.8/4.7/4.6 only)
|
||||
export const EFFORT_ULTRACODE = '◆' // \u25c6 - effort level: ultracode (code-oriented blue/cyan visual)
|
||||
|
||||
// Media/trigger status indicators
|
||||
export const PLAY_ICON = '\u25b6' // ▶
|
||||
|
||||
+17
-2
@@ -217,7 +217,7 @@ import { diagnosticTracker } from '../services/diagnosticTracking.js';
|
||||
import { handleSpeculationAccept, type ActiveSpeculationState } from '../services/PromptSuggestion/speculation.js';
|
||||
import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js';
|
||||
import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCallout.js';
|
||||
import type { EffortValue } from '../utils/effort.js';
|
||||
import { getDisplayedEffortLevel, type EffortValue } from '../utils/effort.js';
|
||||
import { RemoteCallout } from '../components/RemoteCallout.js';
|
||||
import { getAPIProvider } from '../utils/model/providers.js';
|
||||
import { activityManager } from '../utils/activityManager.js';
|
||||
@@ -748,6 +748,14 @@ export function REPL({
|
||||
const terminal = useTerminalNotification();
|
||||
const mainLoopModel = useMainLoopModel();
|
||||
const appMainLoopModel = useAppState(s => s.mainLoopModel);
|
||||
// Ambient ultracode indicator for the prompt border. Reactive to /effort so
|
||||
// the border flips the moment ultracode is (de)selected, independent of the
|
||||
// per-turn spinner color (which is set at query start in onQuery).
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
const isUltracode = useMemo(
|
||||
() => getDisplayedEffortLevel(mainLoopModel, effortValue) === 'ultracode',
|
||||
[mainLoopModel, effortValue],
|
||||
);
|
||||
const appMainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
||||
const initialAgentModelSelection = !hasExplicitModelOverride && initialMainThreadAgentDefinition?.model && initialMainThreadAgentDefinition.model !== 'inherit' ? getActiveSessionAgentModelSelection({
|
||||
agent: initialMainThreadAgentDefinition,
|
||||
@@ -3069,6 +3077,13 @@ export function REPL({
|
||||
effortValue: effort
|
||||
});
|
||||
}
|
||||
// Set blue/cyan spinner color for ultracode mode so the thinking
|
||||
// indicator is visually distinct from regular thinking/ultrathink.
|
||||
const effectiveEffort = effort ?? store.getState().effortValue;
|
||||
if (getDisplayedEffortLevel(mainLoopModelParam, effectiveEffort) === 'ultracode') {
|
||||
setSpinnerColor('ultracode');
|
||||
setSpinnerShimmerColor('ultracodeShimmer');
|
||||
}
|
||||
queryCheckpoint('query_context_loading_start');
|
||||
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
||||
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
||||
@@ -5265,7 +5280,7 @@ export function REPL({
|
||||
{isBuddyEnabled() && companionVisible && !companionNarrow && <CompanionActionFX />}
|
||||
<PromptInput debug={debug} ideSelection={ideSelection} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={renderCommands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={
|
||||
// Works during isLoading — edit cancels first; uuid selection survives appends.
|
||||
feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? enterMessageActions : undefined} mcpClients={mcpClients} pastedContents={pastedContents} setPastedContents={setPastedContents} vimMode={vimMode} setVimMode={setVimMode} showBashesDialog={showBashesDialog} setShowBashesDialog={setShowBashesDialog} onSubmit={onSubmit} onAgentSubmit={onAgentSubmit} isSearchingHistory={isSearchingHistory} setIsSearchingHistory={setIsSearchingHistory} helpOpen={isHelpOpen} setHelpOpen={setIsHelpOpen} insertTextRef={feature('VOICE_MODE') ? insertTextRef : undefined} voiceInterimRange={voice.interimRange} />
|
||||
feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? enterMessageActions : undefined} mcpClients={mcpClients} pastedContents={pastedContents} setPastedContents={setPastedContents} vimMode={vimMode} setVimMode={setVimMode} showBashesDialog={showBashesDialog} setShowBashesDialog={setShowBashesDialog} onSubmit={onSubmit} onAgentSubmit={onAgentSubmit} isSearchingHistory={isSearchingHistory} setIsSearchingHistory={setIsSearchingHistory} helpOpen={isHelpOpen} setHelpOpen={setIsHelpOpen} insertTextRef={feature('VOICE_MODE') ? insertTextRef : undefined} voiceInterimRange={voice.interimRange} ultracodeActive={isUltracode} />
|
||||
<SessionBackgroundHint onBackgroundSession={handleBackgroundSession} isLoading={isLoading} />
|
||||
</>}
|
||||
{cursor &&
|
||||
|
||||
@@ -91,6 +91,9 @@ export type Theme = {
|
||||
rainbow_blue_shimmer: string
|
||||
rainbow_indigo_shimmer: string
|
||||
rainbow_violet_shimmer: string
|
||||
// Ultracode mode colors (blue/cyan code-oriented visual)
|
||||
ultracode: string
|
||||
ultracodeShimmer: string
|
||||
}
|
||||
|
||||
export const THEME_NAMES = [
|
||||
@@ -195,6 +198,8 @@ const lightTheme: Theme = {
|
||||
rainbow_blue_shimmer: 'rgb(180,205,240)',
|
||||
rainbow_indigo_shimmer: 'rgb(195,180,230)',
|
||||
rainbow_violet_shimmer: 'rgb(230,180,210)',
|
||||
ultracode: 'rgb(0,180,216)', // Cyan-blue for ultracode mode
|
||||
ultracodeShimmer: 'rgb(77,215,255)', // Brighter cyan-blue shimmer
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,6 +283,8 @@ const lightAnsiTheme: Theme = {
|
||||
rainbow_blue_shimmer: 'ansi:cyanBright',
|
||||
rainbow_indigo_shimmer: 'ansi:blueBright',
|
||||
rainbow_violet_shimmer: 'ansi:magentaBright',
|
||||
ultracode: 'ansi:cyanBright',
|
||||
ultracodeShimmer: 'ansi:cyanBright',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,6 +368,8 @@ const darkAnsiTheme: Theme = {
|
||||
rainbow_blue_shimmer: 'ansi:cyanBright',
|
||||
rainbow_indigo_shimmer: 'ansi:blueBright',
|
||||
rainbow_violet_shimmer: 'ansi:magentaBright',
|
||||
ultracode: 'ansi:cyanBright',
|
||||
ultracodeShimmer: 'ansi:cyanBright',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,6 +453,8 @@ const lightDaltonizedTheme: Theme = {
|
||||
rainbow_blue_shimmer: 'rgb(180,205,240)',
|
||||
rainbow_indigo_shimmer: 'rgb(195,180,230)',
|
||||
rainbow_violet_shimmer: 'rgb(230,180,210)',
|
||||
ultracode: 'rgb(0,180,216)', // Cyan-blue for ultracode mode
|
||||
ultracodeShimmer: 'rgb(77,215,255)', // Brighter cyan-blue shimmer
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -527,6 +538,8 @@ const darkTheme: Theme = {
|
||||
rainbow_blue_shimmer: 'rgb(180,205,240)',
|
||||
rainbow_indigo_shimmer: 'rgb(195,180,230)',
|
||||
rainbow_violet_shimmer: 'rgb(230,180,210)',
|
||||
ultracode: 'rgb(0,180,216)', // Cyan-blue for ultracode mode
|
||||
ultracodeShimmer: 'rgb(77,215,255)', // Brighter cyan-blue shimmer
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,6 +623,8 @@ const darkDaltonizedTheme: Theme = {
|
||||
rainbow_blue_shimmer: 'rgb(180,205,240)',
|
||||
rainbow_indigo_shimmer: 'rgb(195,180,230)',
|
||||
rainbow_violet_shimmer: 'rgb(230,180,210)',
|
||||
ultracode: 'rgb(0,180,216)', // Cyan-blue for ultracode mode
|
||||
ultracodeShimmer: 'rgb(77,215,255)', // Brighter cyan-blue shimmer
|
||||
}
|
||||
|
||||
export function getTheme(themeName: ThemeName): Theme {
|
||||
|
||||
Reference in New Issue
Block a user