UI/upgraded UI (#1605)

* feat(ui): OpenClaude brand identity — orange accent, wordmark, dot-pulse spinner

Give OpenClaude a real visual identity instead of reskinned upstream visuals:

- theme.ts: add brand/brandShimmer keys and repoint the claude family
  (claude, claudeShimmer, clawd_body, briefLabelClaude) to the gitlawb
  orange #ff7a1a across all 6 themes — darkened variants for light
  backgrounds, luminance-separated variants for daltonized themes,
  redBright floor for 16-color ANSI themes. Values stay rgb() strings
  (parseRGB in Spinner/utils.ts silently fails on hex).
- brand.ts (new): BRAND_NAME, BRAND_TAGLINE, accent constant, and a
  2-row Unicode half-block OPENCLAUDE wordmark split for two-tone render.
- LogoV2: full logo shows the wordmark + centralized tagline;
  CondensedLogo drops the duplicated OPEN CLAUDE header and renders a
  brand-colored name+version line; Clawd mascot body now clawd_body.
- Startup splash: new 'ember' gradient palette (brand orange) as the
  default (/logo keeps sunset et al.); tagline centralized from brand.ts.
- Spinner: dot-pulse glyph sweep (· ∘ ○ ◎ ◉ ●) replacing the asterisk
  set (drops Ghostty/darwin special-cases); defaults switched to
  brand/brandShimmer. Reduced-motion and stall interpolation unchanged.
- figures.ts: TEARDROP_ASTERISK marker → ◎ to match the new glyph family.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* feat(ui): built-in default statusline

Ship a status bar that renders when no custom /statusline command is
configured: `model · ctx % · session cost · rate limit`. Previously this
slot was empty unless the user wired up their own shell command.

- BuiltinStatusLine.tsx (new): pure segment builders
  (buildBuiltinStatusSegments / fitSegments — drops lowest-priority
  segments right-to-left as the terminal narrows) plus a memoized
  component reusing the exact data pipeline the custom StatusLine feeds
  to user commands (getRuntimeMainLoopModel, getCurrentUsage,
  calculateContextPercentages, getTotalCost, getRawUtilization). Pure
  in-process computation — no subprocess, no debounce.
- Context % colors warning ≥70 / error ≥90 (aligned with auto-compact
  warnings); rate limit shows the worst of the 5h/7d windows and is
  absent (not 0%) for API-key users; cost hidden at $0.
- PromptInputFooter: custom statusline always wins; the built-in bar
  also suppresses the "? for shortcuts" hint like the custom one does.
- defaultStatusLineEnabled global-config toggle (default on) surfaced in
  /config settings.
- Unit tests for segment building, narrow-width fitting, threshold
  colors, and custom-statusline precedence.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* feat(ui): polish pass — fuzzy match highlighting, completion flash, diff gutter, dialog hints

- Fuzzy pickers: new highlightFuzzyMatch (contiguous-run or greedy-
  subsequence, bold so the focused row's color survives) applied to
  Quick Open and prompt-history results. FuzzyPicker now passes the live
  query as a third renderItem argument so memoized render callbacks
  can't capture a stale query.
- Select: inline option descriptions now truncate with an ellipsis on
  narrow terminals instead of clipping at the edge (row made shrinkable,
  description wrapped in truncate-end).
- CompletionFlash (new): static `✓ Done · 12s` row for ~1.5s after a
  response completes. Keys off the isLoading transition (the spinner
  also hides mid-turn for streaming text); same row footprint as the
  spinner; suppressed for sub-second turns, brief mode, open
  permission/prompt queues, running teammates, and reduced motion.
- Diff gutter: line numbers on +/- lines are now dimmed (previously
  full decoration intensity competed with content, worst on light
  themes); +/- sigils keep full intensity. Mirrored in the
  non-highlighted Fallback renderer.
- Dialog: optional showNavigationHint prepends "↑/↓ navigate" to the
  default input guide; opted in from Select-hosting dialogs
  (IdleReturn, DevChannels, WorktreeExit). Dialog rewritten as plain
  React (was react-compiler output) to take the new prop safely.
- Spinner: the ↑/↓ request-direction glyph now leads the status parens
  whenever any status shows — previously buried in the tokens part,
  which only appears after 30s; width gating reserves its space.
- Replaced the stale `/`-search TODO in ScrollKeybindingHandler with a
  pointer to the shipped REPL transcript search.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(ui): address review — explicit statusline default, narrow-terminal thinking gate

- createDefaultGlobalConfig now sets defaultStatusLineEnabled: true
  explicitly, matching the factory's other default-true booleans (read
  sites already coalesced to true; no behavior change).
- SpinnerAnimationRow: the width gate reserves mode-glyph space that a
  thinking-only spin never uses; on narrow terminals where nothing else
  renders, re-try the thinking gate with that space returned so
  '(thinking)' shows instead of nothing.

CompletionFlash NaN guard was reviewed and not applied: both refs are
useRef(0) — initialized numbers by type and construction — and the
active→inactive transition guard guarantees the start time was set
before the elapsed math runs.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(ui): address review round 2 — hint accuracy, flash suppression, coverage

- Dialog: the cancel shortcut hint now renders only when isCancelActive,
  matching the keybinding it advertises.
- PromptInputFooter: new exported resolveFooterStatusLine(settings,
  guards, config?) is the single source of truth for which status line
  renders — custom wins over builtin, and all render guards (prompt
  mode, short fullscreen, exit message, pasting) force null. suppressHint
  and the render now share it, so '? for shortcuts' is suppressed only
  when a status line actually renders.
- CompletionFlash: suppression now clears an already-active flash (the
  effect resets state and the render guard skips the commit-gap frame)
  instead of only preventing new ones.
- REPL: flash suppression reuses the existing hasActivePrompt aggregate,
  which covers the sandbox, worker-sandbox, and elicitation queues the
  hand-rolled expression missed.
- builtinStatusLineShouldDisplay takes an injectable config (defaults to
  getGlobalConfig()) so the config-off path is testable without module
  mocks; tests added for config-off, custom-wins-regardless, and the
  resolver's full variant x guard matrix.

Validation: typecheck exit 0; full suite 3733 green; smoke + bundle
guard green.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(ui): drop fit-budget floor, cover reduced-motion render gap

- BuiltinStatusLine: remove the artificial 10-column floor on the fit
  budget — fitSegments returning [] is the signal that nothing fits,
  and the empty branch already handles it (row-reserve in fullscreen,
  null otherwise). The floor forced a truncated segment onto panes
  narrower than the model name.
- CompletionFlash: the render guard now also skips the one frame
  between reducedMotion flipping on and the effect clearing the flash,
  same as the suppressed case.

Component-level mount tests for PromptInputFooter were considered and
deliberately not added: the footer needs 10+ mocked contexts to mount,
mock.module harnesses leak across bun test files in this repo, and the
branching under review is exactly the pure resolveFooterStatusLine
contract already pinned by tests.

Validation: typecheck exit 0; full suite 3733 green; smoke + bundle
guard green.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
Kevin Codex
2026-06-12 21:59:32 +08:00
committed by GitHub
co-authored by OpenClaude
parent 3752dfe6f7
commit 2002e4c116
32 changed files with 823 additions and 175 deletions
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'bun:test'
import { DEFAULT_GLOBAL_CONFIG } from '../utils/config.js'
import {
type BuiltinStatusData,
buildBuiltinStatusSegments,
builtinStatusLineShouldDisplay,
fitSegments,
} from './BuiltinStatusLine.js'
const fullData: BuiltinStatusData = {
modelName: 'Opus 4.8',
contextUsedPercent: 37.4,
costUSD: 1.234,
rateLimit: { label: '5h', usedPercent: 42 },
}
describe('buildBuiltinStatusSegments', () => {
it('builds all segments when every datum is present', () => {
const segments = buildBuiltinStatusSegments(fullData)
expect(segments.map(s => s.key)).toEqual([
'model',
'context',
'cost',
'rateLimit',
])
expect(segments.map(s => s.text)).toEqual([
'Opus 4.8',
'ctx 37%',
'$1.23',
'5h 42%',
])
})
it('omits context before the first assistant turn', () => {
const segments = buildBuiltinStatusSegments({
...fullData,
contextUsedPercent: null,
})
expect(segments.find(s => s.key === 'context')).toBeUndefined()
})
it('omits cost at $0 and rate limit without utilization data', () => {
const segments = buildBuiltinStatusSegments({
...fullData,
costUSD: 0,
rateLimit: null,
})
expect(segments.map(s => s.key)).toEqual(['model', 'context'])
})
it('colors context by usage thresholds', () => {
const at = (pct: number) =>
buildBuiltinStatusSegments({ ...fullData, contextUsedPercent: pct }).find(
s => s.key === 'context',
)?.color
expect(at(50)).toBeUndefined()
expect(at(70)).toBe('warning')
expect(at(90)).toBe('error')
})
it('colors rate limit by usage thresholds', () => {
const at = (pct: number) =>
buildBuiltinStatusSegments({
...fullData,
rateLimit: { label: '7d', usedPercent: pct },
}).find(s => s.key === 'rateLimit')?.color
expect(at(30)).toBeUndefined()
expect(at(60)).toBe('warning')
expect(at(85)).toBe('error')
})
})
describe('fitSegments', () => {
const segments = buildBuiltinStatusSegments(fullData)
// 'Opus 4.8 · ctx 37% · $1.23 · 5h 42%' = 35 cols
it('keeps everything when the line fits', () => {
expect(fitSegments(segments, 120)).toHaveLength(4)
})
it('drops highest-priority-number segments first when narrow', () => {
const fitted = fitSegments(segments, 30)
expect(fitted.map(s => s.key)).toEqual(['model', 'context', 'cost'])
})
it('keeps only the model at very narrow widths', () => {
const fitted = fitSegments(segments, 10)
expect(fitted.map(s => s.key)).toEqual(['model'])
})
it('returns empty when even the model does not fit', () => {
expect(fitSegments(segments, 3)).toEqual([])
})
})
describe('builtinStatusLineShouldDisplay', () => {
it('yields to a configured custom statusline', () => {
expect(
builtinStatusLineShouldDisplay({
statusLine: { type: 'command', command: 'echo custom' },
}),
).toBe(false)
})
it('displays by default when no custom statusline is configured', () => {
expect(builtinStatusLineShouldDisplay({})).toBe(true)
})
it('hides when defaultStatusLineEnabled is false', () => {
expect(
builtinStatusLineShouldDisplay(
{},
{ ...DEFAULT_GLOBAL_CONFIG, defaultStatusLineEnabled: false },
),
).toBe(false)
})
it('config off still yields to nothing — custom statusline wins regardless', () => {
expect(
builtinStatusLineShouldDisplay(
{ statusLine: { type: 'command', command: 'echo custom' } },
{ ...DEFAULT_GLOBAL_CONFIG, defaultStatusLineEnabled: false },
),
).toBe(false)
})
})
+183
View File
@@ -0,0 +1,183 @@
import { feature } from 'bun:bundle';
import * as React from 'react';
import { memo, useMemo } from 'react';
import { getKairosActive, getSdkBetas } from '../bootstrap/state.js';
import { getTotalCost } from '../cost-tracker.js';
import { useMainLoopModel } from '../hooks/useMainLoopModel.js';
import type { ReadonlySettings } from '../hooks/useSettings.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { Box, Text } from '../ink.js';
import { getRawUtilization } from '../services/claudeAiLimits.js';
import { useClaudeAiLimits } from '../services/claudeAiLimitsHook.js';
import { useAppState } from '../state/AppState.js';
import type { Message } from '../types/message.js';
import { getGlobalConfig } from '../utils/config.js';
import { calculateContextPercentages, getContextWindowForModel } from '../utils/context.js';
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
import { getRuntimeMainLoopModel, renderModelName } from '../utils/model/model.js';
import type { Theme } from '../utils/theme.js';
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
/**
* Built-in status bar shown when the user has NOT configured a custom
* /statusline command: `model · ctx % · session cost · rate limit`.
* A configured custom statusline always takes precedence (see the ternary in
* PromptInputFooter). Unlike the custom path this is pure in-process
* computation — no subprocess, no debounce.
*/
export function builtinStatusLineShouldDisplay(settings: ReadonlySettings, config = getGlobalConfig()): boolean {
// Assistant mode: fields reflect the REPL/daemon process, not the agent
// child — hide, same as statusLineShouldDisplay.
if (feature('KAIROS') && getKairosActive()) return false;
// A configured custom statusline command always wins.
if (settings?.statusLine !== undefined) return false;
return config.defaultStatusLineEnabled ?? true;
}
export type StatusSegment = {
key: string;
/** Lower survives longer when the terminal narrows. */
priority: number;
text: string;
color?: keyof Theme;
};
const SEPARATOR = ' · ';
export type BuiltinStatusData = {
modelName: string;
/** 0100, or null before the first assistant turn. */
contextUsedPercent: number | null;
costUSD: number;
/** Worst rate-limit window, or null when no utilization data (API-key users). */
rateLimit: {
label: string;
usedPercent: number;
} | null;
};
export function buildBuiltinStatusSegments(data: BuiltinStatusData): StatusSegment[] {
const segments: StatusSegment[] = [{
key: 'model',
priority: 0,
text: data.modelName
}];
if (data.contextUsedPercent !== null) {
const pct = Math.round(data.contextUsedPercent);
segments.push({
key: 'context',
priority: 1,
text: `ctx ${pct}%`,
// Thresholds align with the auto-compact warnings
color: pct >= 90 ? 'error' : pct >= 70 ? 'warning' : undefined
});
}
if (data.costUSD > 0) {
const cost = data.costUSD;
segments.push({
key: 'cost',
priority: 2,
text: cost >= 100 ? `$${cost.toFixed(0)}` : `$${cost.toFixed(2)}`
});
}
if (data.rateLimit) {
const pct = Math.round(data.rateLimit.usedPercent);
segments.push({
key: 'rateLimit',
priority: 3,
text: `${data.rateLimit.label} ${pct}%`,
color: pct >= 85 ? 'error' : pct >= 60 ? 'warning' : undefined
});
}
return segments;
}
/** Drops the highest-priority-number segments until the joined line fits. */
export function fitSegments(segments: StatusSegment[], maxWidth: number): StatusSegment[] {
const fits = (segs: StatusSegment[]): boolean => {
if (segs.length === 0) return true;
const width = segs.reduce((sum, s) => sum + s.text.length, 0) + (segs.length - 1) * SEPARATOR.length;
return width <= maxWidth;
};
const result = [...segments];
while (result.length > 1 && !fits(result)) {
let dropIndex = 0;
for (let i = 1; i < result.length; i++) {
if (result[i]!.priority > result[dropIndex]!.priority) dropIndex = i;
}
result.splice(dropIndex, 1);
}
return fits(result) ? result : [];
}
/** Worst (most-used) of the 5h/7d rate-limit windows, or null without data. */
export function getWorstRateLimit(): BuiltinStatusData['rateLimit'] {
const raw = getRawUtilization();
const candidates: { label: string; usedPercent: number }[] = [];
if (raw.five_hour) candidates.push({ label: '5h', usedPercent: raw.five_hour.utilization * 100 });
if (raw.seven_day) candidates.push({ label: '7d', usedPercent: raw.seven_day.utilization * 100 });
if (candidates.length === 0) return null;
return candidates.reduce((worst, c) => c.usedPercent > worst.usedPercent ? c : worst);
}
type Props = {
// Same contract as StatusLine: messages stay behind a ref;
// lastAssistantMessageId is the re-render/recompute trigger.
messagesRef: React.RefObject<Message[]>;
lastAssistantMessageId: string | null;
};
function BuiltinStatusLineInner({
messagesRef,
lastAssistantMessageId
}: Props): React.ReactNode {
const permissionMode = useAppState(s => s.toolPermissionContext.mode);
// AppState-sourced model — same source as API requests (see StatusLine).
const mainLoopModel = useMainLoopModel();
// Subscribe to rate-limit header updates so the segment stays fresh.
useClaudeAiLimits();
const {
columns
} = useTerminalSize();
const computed = useMemo(() => {
const msgs = messagesRef.current;
const exceeds200kTokens = doesMostRecentAssistantMessageExceed200k(msgs);
const runtimeModel = getRuntimeMainLoopModel({
permissionMode,
mainLoopModel,
exceeds200kTokens
});
const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas());
const contextPercentages = calculateContextPercentages(getCurrentUsage(msgs), contextWindowSize);
return {
modelName: renderModelName(runtimeModel),
contextUsedPercent: contextPercentages.used,
costUSD: getTotalCost()
};
// messagesRef is stable; lastAssistantMessageId is the messages-changed signal
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, [lastAssistantMessageId, permissionMode, mainLoopModel, messagesRef]);
const segments = fitSegments(buildBuiltinStatusSegments({
...computed,
rateLimit: getWorstRateLimit()
}),
// paddingX from the footer (2 each side) is already outside this Box;
// keep a 1-col safety margin against the truncate ellipsis. No artificial
// floor: fitSegments returning [] is the signal that nothing fits, and the
// empty branch below already handles it (row-reserve in fullscreen, null
// otherwise).
Math.max(columns - 5, 0));
// Reserve the row in fullscreen — the footer is flexShrink:0, so a 0→1 row
// change steals a row from ScrollBox (same trick as StatusLine).
if (segments.length === 0) {
return isFullscreenEnvEnabled() ? <Text> </Text> : null;
}
return <Box>
<Text wrap="truncate">
{segments.map((segment, index) => <Text key={segment.key}>
{index > 0 ? <Text dimColor>{SEPARATOR}</Text> : null}
<Text color={segment.color} dimColor={segment.color === undefined}>{segment.text}</Text>
</Text>)}
</Text>
</Box>;
}
// Parent (PromptInputFooter) re-renders on every setMessages; memo keeps this
// to lastAssistantMessageId flips (same rationale as StatusLine).
export const BuiltinStatusLine = memo(BuiltinStatusLineInner);
+1 -1
View File
@@ -589,7 +589,7 @@ export function Select(t0) {
const isSelected_3 = state.value === option_4.value;
const isOptionDisabled_2 = option_4.disabled === true;
const optionColor_2 = isOptionDisabled_2 ? undefined : option_4.color ?? (isSelected_3 ? "success" : isFocused_3 ? "suggestion" : undefined);
return <SelectOption key={String(option_4.value)} isFocused={isFocused_3} isSelected={isSelected_3} shouldShowDownArrow={areMoreOptionsBelow_3 && isLastVisibleOption_3} shouldShowUpArrow={areMoreOptionsAbove_3 && isFirstVisibleOption_3}><Box flexDirection="row" flexShrink={0}>{!hideIndexes && <Text dimColor={true}>{`${i_3}.`.padEnd(maxIndexWidth_1 + 2)}</Text>}<Text dimColor={isOptionDisabled_2} color={optionColor_2}>{label_2}{inlineDescriptions && option_4.description && <Text dimColor={isOptionDisabled_2 || option_4.dimDescription !== false}>{" "}{option_4.description}</Text>}</Text></Box>{!inlineDescriptions && option_4.description && <Box flexShrink={99} marginLeft={2}><Text wrap="wrap-trim" dimColor={isOptionDisabled_2 || option_4.dimDescription !== false} color={optionColor_2}><Ansi>{option_4.description}</Ansi></Text></Box>}</SelectOption>;
return <SelectOption key={String(option_4.value)} isFocused={isFocused_3} isSelected={isSelected_3} shouldShowDownArrow={areMoreOptionsBelow_3 && isLastVisibleOption_3} shouldShowUpArrow={areMoreOptionsAbove_3 && isFirstVisibleOption_3}><Box flexDirection="row" flexShrink={1}>{!hideIndexes && <Text dimColor={true}>{`${i_3}.`.padEnd(maxIndexWidth_1 + 2)}</Text>}<Text dimColor={isOptionDisabled_2} color={optionColor_2}>{label_2}</Text>{inlineDescriptions && option_4.description && <Box flexShrink={1}><Text wrap="truncate-end" dimColor={isOptionDisabled_2 || option_4.dimDescription !== false} color={optionColor_2}>{" "}{option_4.description}</Text></Box>}</Box>{!inlineDescriptions && option_4.description && <Box flexShrink={99} marginLeft={2}><Text wrap="wrap-trim" dimColor={isOptionDisabled_2 || option_4.dimDescription !== false} color={optionColor_2}><Ansi>{option_4.description}</Ansi></Text></Box>}</SelectOption>;
});
}
$[28] = hideIndexes;
+1 -1
View File
@@ -86,7 +86,7 @@ export function DevChannelsDialog(t0: Props) {
}
let t8;
if ($[11] !== t5 || $[12] !== t7) {
t8 = <Dialog title="WARNING: Loading development channels" color="error" onCancel={handleEscape}>{t5}{t7}</Dialog>;
t8 = <Dialog title="WARNING: Loading development channels" color="error" onCancel={handleEscape} showNavigationHint={true}>{t5}{t7}</Dialog>;
$[11] = t5;
$[12] = t7;
$[13] = t8;
+3 -2
View File
@@ -9,6 +9,7 @@ import { Box, Text } from '../ink.js';
import { logEvent } from '../services/analytics/index.js';
import type { HistoryEntry } from '../utils/config.js';
import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js';
import { highlightFuzzyMatch } from '../utils/highlightMatch.js';
import { FuzzyPicker } from './design-system/FuzzyPicker.js';
type Props = {
initialQuery?: string;
@@ -87,11 +88,11 @@ export function HistorySearchDialog({
query_length: query.length
});
void item_1.entry.resolve().then(onSelect);
}} onCancel={onCancel} emptyMessage={q_0 => items === null ? 'Loading…' : q_0 ? 'No matching prompts' : 'No history yet'} selectAction="use" direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} renderItem={(item_2, isFocused) => <Text>
}} onCancel={onCancel} emptyMessage={q_0 => items === null ? 'Loading…' : q_0 ? 'No matching prompts' : 'No history yet'} selectAction="use" direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} renderItem={(item_2, isFocused, pickerQuery) => <Text>
<Text dimColor>{item_2.age}</Text>
<Text color={isFocused ? 'suggestion' : undefined}>
{' '}
{truncateToWidth(item_2.firstLine, rowWidth)}
{highlightFuzzyMatch(truncateToWidth(item_2.firstLine, rowWidth), pickerQuery)}
</Text>
</Text>} renderPreview={item_3 => {
const wrapped = wrapAnsi(item_3.display, previewWidth, {
+1 -1
View File
@@ -91,7 +91,7 @@ export function IdleReturnDialog(t0) {
}
let t10;
if ($[12] !== t3 || $[13] !== t4 || $[14] !== t9) {
t10 = <Dialog title={t3} onCancel={t4}>{t5}{t9}</Dialog>;
t10 = <Dialog title={t3} onCancel={t4} showNavigationHint={true}>{t5}{t9}</Dialog>;
$[12] = t3;
$[13] = t4;
$[14] = t9;
+9 -9
View File
@@ -98,7 +98,7 @@ export function Clawd(t0) {
const p = POSES[pose];
let t3;
if ($[4] !== p.r1L) {
t3 = <Text color="text">{p.r1L}</Text>;
t3 = <Text color="clawd_body">{p.r1L}</Text>;
$[4] = p.r1L;
$[5] = t3;
} else {
@@ -106,7 +106,7 @@ export function Clawd(t0) {
}
let t4;
if ($[6] !== p.r1E) {
t4 = <Text color="text">{p.r1E}</Text>;
t4 = <Text color="clawd_body">{p.r1E}</Text>;
$[6] = p.r1E;
$[7] = t4;
} else {
@@ -114,7 +114,7 @@ export function Clawd(t0) {
}
let t5;
if ($[8] !== p.r1R) {
t5 = <Text color="text">{p.r1R}</Text>;
t5 = <Text color="clawd_body">{p.r1R}</Text>;
$[8] = p.r1R;
$[9] = t5;
} else {
@@ -132,7 +132,7 @@ export function Clawd(t0) {
}
let t7;
if ($[14] !== p.r2L) {
t7 = <Text color="text">{p.r2L}</Text>;
t7 = <Text color="clawd_body">{p.r2L}</Text>;
$[14] = p.r2L;
$[15] = t7;
} else {
@@ -140,14 +140,14 @@ export function Clawd(t0) {
}
let t8;
if ($[16] === Symbol.for("react.memo_cache_sentinel")) {
t8 = <Text color="text"> OC </Text>;
t8 = <Text color="clawd_body"> OC </Text>;
$[16] = t8;
} else {
t8 = $[16];
}
let t9;
if ($[17] !== p.r2R) {
t9 = <Text color="text">{p.r2R}</Text>;
t9 = <Text color="clawd_body">{p.r2R}</Text>;
$[17] = p.r2R;
$[18] = t9;
} else {
@@ -187,7 +187,7 @@ function AppleTerminalClawd(t0) {
} = t0;
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <Text color="text"></Text>;
t1 = <Text color="clawd_body"></Text>;
$[0] = t1;
} else {
t1 = $[0];
@@ -195,7 +195,7 @@ function AppleTerminalClawd(t0) {
const t2 = APPLE_EYES[pose];
let t3;
if ($[1] !== t2) {
t3 = <Text color="text">{t2}</Text>;
t3 = <Text color="clawd_body">{t2}</Text>;
$[1] = t2;
$[2] = t3;
} else {
@@ -203,7 +203,7 @@ function AppleTerminalClawd(t0) {
}
let t4;
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
t4 = <Text color="text"></Text>;
t4 = <Text color="clawd_body"></Text>;
$[3] = t4;
} else {
t4 = $[3];
+4 -3
View File
@@ -1,6 +1,7 @@
import { c as _c } from "react-compiler-runtime";
import * as React from 'react';
import { type ReactNode, useEffect } from 'react';
import { BRAND_NAME, BRAND_TAGLINE } from '../../constants/brand.js';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { stringWidth } from '../../ink/stringWidth.js';
@@ -88,7 +89,7 @@ export function CondensedLogo() {
}
let t5;
if ($[8] === Symbol.for("react.memo_cache_sentinel")) {
t5 = <Text bold={true}>OPEN CLAUDE</Text>;
t5 = <Text bold={true} color="brand">{BRAND_NAME}</Text>;
$[8] = t5;
} else {
t5 = $[8];
@@ -101,7 +102,7 @@ export function CondensedLogo() {
} else {
t6 = $[10];
}
const t6a = 'Open terminal for any LLM';
const t6a = BRAND_TAGLINE;
let t7;
if ($[11] !== shouldSplit || $[12] !== truncatedBilling || $[13] !== truncatedModel) {
t7 = shouldSplit ? <><Text><Text color="inactive">Model</Text><Text dimColor={true}> {truncatedModel}</Text></Text><Text><Text color="inactive">Mode</Text><Text dimColor={true}> {truncatedBilling}</Text></Text></> : <Text><Text color="inactive">Model</Text><Text dimColor={true}> {truncatedModel} · {truncatedBilling}</Text></Text>;
@@ -141,7 +142,7 @@ export function CondensedLogo() {
}
let t12;
if ($[23] !== t10 || $[24] !== t11 || $[25] !== t6 || $[26] !== t7 || $[27] !== t9) {
t12 = <OffscreenFreeze><Box borderStyle="round" borderColor="inactive" paddingX={2} paddingY={0} flexDirection="row" gap={2} alignItems="center"><Box flexDirection="column" alignItems="center"><Text color="inactive"></Text>{t4}<Text color="inactive"></Text></Box><Box flexDirection="column"><Text bold={true}>OPEN CLAUDE</Text><Text dimColor={true}>{t6a}</Text><Box marginTop={1}>{t6}</Box>{t7}{t9}{t10}{t11}</Box></Box></OffscreenFreeze>;
t12 = <OffscreenFreeze><Box borderStyle="round" borderColor="inactive" paddingX={2} paddingY={0} flexDirection="row" gap={2} alignItems="center"><Box flexDirection="column" alignItems="center"><Text color="inactive"></Text>{t4}<Text color="inactive"></Text></Box><Box flexDirection="column">{t6}<Text dimColor={true}>{t6a}</Text><Box marginTop={1} flexDirection="column">{t7}{t9}</Box>{t10}{t11}</Box></Box></OffscreenFreeze>;
$[23] = t10;
$[24] = t11;
$[25] = t6;
+2 -1
View File
@@ -7,6 +7,7 @@ import { stringWidth } from '../../ink/stringWidth.js';
import { getLayoutMode, calculateLayoutDimensions, calculateOptimalLeftWidth, formatWelcomeMessage, truncatePath, getRecentActivitySync, getRecentReleaseNotesSync, getLogoDisplayData } from '../../utils/logoV2Utils.js';
import { truncate } from '../../utils/format.js';
import { getDisplayPath } from '../../utils/file.js';
import { BRAND_TAGLINE, WORDMARK_CLAUDE, WORDMARK_OPEN } from '../../constants/brand.js';
import { Clawd } from './Clawd.js';
import { FeedColumn } from './FeedColumn.js';
import { createRecentActivityFeed, createWhatsNewFeed, createProjectOnboardingFeed, createGuestPassesFeed } from './feedConfigs.js';
@@ -373,7 +374,7 @@ export function LogoV2() {
const t17 = 1;
let t18;
if ($[46] !== welcomeMessage_0) {
t18 = <Box marginTop={1} flexDirection="column" alignItems="center"><Text bold={true}>OPEN CLAUDE</Text><Text dimColor={true}>open terminal for any LLM</Text><Text color="inactive"></Text><Text bold={true}>{welcomeMessage_0}</Text></Box>;
t18 = <Box marginTop={1} flexDirection="column" alignItems="center"><Text><Text color="brandShimmer">{WORDMARK_OPEN[0]}</Text><Text color="brand"> {WORDMARK_CLAUDE[0]}</Text></Text><Text><Text color="brandShimmer">{WORDMARK_OPEN[1]}</Text><Text color="brand"> {WORDMARK_CLAUDE[1]}</Text></Text><Text dimColor={true}>{BRAND_TAGLINE}</Text><Text color="inactive"></Text><Text bold={true}>{welcomeMessage_0}</Text></Box>;
$[46] = welcomeMessage_0;
$[47] = t18;
} else {
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'bun:test'
import { DEFAULT_GLOBAL_CONFIG } from '../../utils/config.js'
import { resolveFooterStatusLine } from './PromptInputFooter.js'
const guardsPass = {
isPromptMode: true,
isShort: false,
exitMessageShown: false,
isPasting: false,
}
const customSettings = {
statusLine: { type: 'command' as const, command: 'echo custom' },
}
describe('resolveFooterStatusLine', () => {
it('picks the custom statusline when one is configured', () => {
expect(resolveFooterStatusLine(customSettings, guardsPass)).toBe('custom')
})
it('falls back to the builtin statusline when no custom one is configured', () => {
expect(resolveFooterStatusLine({}, guardsPass)).toBe('builtin')
})
it('renders none when the builtin statusline is disabled in config', () => {
expect(
resolveFooterStatusLine({}, guardsPass, {
...DEFAULT_GLOBAL_CONFIG,
defaultStatusLineEnabled: false,
}),
).toBeNull()
})
it('custom statusline still wins when builtin is disabled', () => {
expect(
resolveFooterStatusLine(customSettings, guardsPass, {
...DEFAULT_GLOBAL_CONFIG,
defaultStatusLineEnabled: false,
}),
).toBe('custom')
})
// The `? for shortcuts` hint is suppressed iff this resolver returns
// non-null, so every render guard must force null even when a status
// line is enabled — otherwise the hint vanishes with nothing in its place.
for (const [name, guards] of [
['non-prompt mode', { ...guardsPass, isPromptMode: false }],
['short fullscreen', { ...guardsPass, isShort: true }],
['exit message showing', { ...guardsPass, exitMessageShown: true }],
['paste in progress', { ...guardsPass, isPasting: true }],
] as const) {
it(`renders none while ${name}, for both variants`, () => {
expect(resolveFooterStatusLine({}, guards)).toBeNull()
expect(resolveFooterStatusLine(customSettings, guards)).toBeNull()
})
}
})
@@ -6,7 +6,7 @@ import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js';
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js';
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js';
import type { IDESelection } from '../../hooks/useIdeSelection.js';
import { useSettings } from '../../hooks/useSettings.js';
import { type ReadonlySettings, useSettings } from '../../hooks/useSettings.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text } from '../../ink.js';
import type { MCPServerConnection } from '../../services/mcp/types.js';
@@ -16,12 +16,33 @@ import type { Message } from '../../types/message.js';
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js';
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import { BuiltinStatusLine, builtinStatusLineShouldDisplay } from '../BuiltinStatusLine.js';
import { useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js';
import { getLastAssistantMessageId, StatusLine, statusLineShouldDisplay } from '../StatusLine.js';
import { Notifications } from './Notifications.js';
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js';
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js';
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js';
/**
* Which status line, if any, the footer renders below the prompt: the
* configured custom command wins over the built-in one, and neither renders
* when the row's guards fail (non-prompt mode, short fullscreen, exit
* message showing, paste in progress). The `? for shortcuts` hint is
* suppressed only when this returns non-null, so the hint never disappears
* in states where no status line actually renders.
*/
export function resolveFooterStatusLine(settings: ReadonlySettings, guards: {
isPromptMode: boolean;
isShort: boolean;
exitMessageShown: boolean;
isPasting: boolean;
}, config?: Parameters<typeof builtinStatusLineShouldDisplay>[1]): 'custom' | 'builtin' | null {
if (!guards.isPromptMode || guards.isShort || guards.exitMessageShown || guards.isPasting) return null;
if (statusLineShouldDisplay(settings)) return 'custom';
if (builtinStatusLineShouldDisplay(settings, config)) return 'builtin';
return null;
}
type Props = {
apiKeyStatus: VerificationStatus;
debug: boolean;
@@ -117,8 +138,15 @@ function PromptInputFooter({
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex);
const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0);
// Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r
const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching;
const footerStatusLine = resolveFooterStatusLine(settings, {
isPromptMode: mode === 'prompt',
isShort,
exitMessageShown: exitMessage.show,
isPasting
});
// Hide `? for shortcuts` only when a status line actually renders below
// (display setting AND render guards), or during ctrl-r search.
const suppressHint = suppressHintFromProps || footerStatusLine !== null || isSearching;
// Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx
const overlayData = useMemo(() => isFullscreen && suggestions.length ? {
suggestions,
@@ -137,7 +165,7 @@ function PromptInputFooter({
return <>
<Box flexDirection={isNarrow ? 'column' : 'row'} justifyContent={isNarrow ? 'flex-start' : 'space-between'} paddingX={2} gap={isNarrow ? 0 : 1}>
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
{mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && <StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} />}
{footerStatusLine === 'custom' ? <StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} /> : footerStatusLine === 'builtin' ? <BuiltinStatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} /> : null}
<PromptInputFooterLeftSide exitMessage={exitMessage} vimMode={vimMode} mode={mode} toolPermissionContext={toolPermissionContext} suppressHint={suppressHint} isLoading={isLoading} tasksSelected={pillSelected} teamsSelected={teamsSelected} teammateFooterIndex={teammateFooterIndex} tmuxSelected={tmuxSelected} isPasting={isPasting} isSearching={isSearching} historyQuery={historyQuery} setHistoryQuery={setHistoryQuery} historyFailedMatch={historyFailedMatch} onOpenTasksDialog={onOpenTasksDialog} />
</Box>
<Box flexShrink={1} gap={1}>
+2 -2
View File
@@ -10,7 +10,7 @@ import { logEvent } from '../services/analytics/index.js';
import { getCwd } from '../utils/cwd.js';
import { openFileInExternalEditor } from '../utils/editor.js';
import { truncatePathMiddle, truncateToWidth } from '../utils/format.js';
import { highlightMatch } from '../utils/highlightMatch.js';
import { highlightFuzzyMatch, highlightMatch } from '../utils/highlightMatch.js';
import { readFileInRange } from '../utils/readFileInRange.js';
import { FuzzyPicker } from './design-system/FuzzyPicker.js';
import { LoadingState } from './design-system/LoadingState.js';
@@ -189,7 +189,7 @@ export function QuickOpenDialog(t0) {
}
let t12;
if ($[19] !== maxPathWidth) {
t12 = (p_6, isFocused) => <Text color={isFocused ? "suggestion" : undefined}>{truncatePathMiddle(p_6, maxPathWidth)}</Text>;
t12 = (p_6, isFocused, pickerQuery) => <Text color={isFocused ? "suggestion" : undefined}>{highlightFuzzyMatch(truncatePathMiddle(p_6, maxPathWidth), pickerQuery ?? "")}</Text>;
$[19] = maxPathWidth;
$[20] = t12;
} else {
+5 -5
View File
@@ -565,11 +565,11 @@ export function ScrollKeybindingHandler({
// ctrl+f → chat:killAgents moved to ctrl+x ctrl+k; no conflict
// g/G → printable chars: no prompt to eat them, no vim/sticky gate needed
//
// TODO(search): `/`, n/N — build on Richard Kim's d94b07add4 (branch
// claude/jump-recent-message-CEPcq). getItemY Yoga-walk + computeOrigin +
// anchorY already solve scroll-to-index. jumpToPrevTurn is the n/N
// template. Single-shot via OVERSCAN_ROWS=80; two-phase was tried and
// abandoned ( oscillation). See team memory scroll-copy-mode-design.md.
// Transcript search (`/`, n/N) shipped in REPL.tsx (TranscriptSearchBar +
// held-key-batched n/N) for the virtual-scroll path. Remaining gap: no
// search when !virtualScrollActive (legacy non-fullscreen transcript) —
// intentionally unimplemented; match positions would need re-deriving
// without VirtualMessageList's jump handle.
useInput((input, key, event) => {
const s_10 = scrollRef.current;
if (!s_10) return;
+21
View File
@@ -501,6 +501,24 @@ export function Config({
enabled: terminalProgressBarEnabled
});
}
}, {
id: 'defaultStatusLineEnabled',
label: 'Default status line',
value: globalConfig.defaultStatusLineEnabled ?? true,
type: 'boolean' as const,
onChange(defaultStatusLineEnabled: boolean) {
saveGlobalConfig(current => ({
...current,
defaultStatusLineEnabled
}));
setGlobalConfig({
...getGlobalConfig(),
defaultStatusLineEnabled
});
logEvent('tengu_default_status_line_setting_changed', {
enabled: defaultStatusLineEnabled
});
}
}, ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false) ? [{
id: 'showStatusInTerminalTab',
label: 'Show status in terminal tab',
@@ -1211,6 +1229,9 @@ export function Config({
if (globalConfig.terminalProgressBarEnabled !== initialConfig.current.terminalProgressBarEnabled) {
formattedChanges.push(`${globalConfig.terminalProgressBarEnabled ? 'Enabled' : 'Disabled'} terminal progress bar`);
}
if (globalConfig.defaultStatusLineEnabled !== initialConfig.current.defaultStatusLineEnabled) {
formattedChanges.push(`${globalConfig.defaultStatusLineEnabled ?? true ? 'Enabled' : 'Disabled'} default status line`);
}
if (globalConfig.showStatusInTerminalTab !== initialConfig.current.showStatusInTerminalTab) {
formattedChanges.push(`${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`);
}
+2 -2
View File
@@ -208,8 +208,8 @@ function SpinnerWithVerbInner({
// the ref. The tree is only shown when teammates are running; teammate
// progress updates to s.tasks trigger re-renders that keep this fresh.
const leaderTokenCount = Math.round(responseLengthRef.current / 4);
const defaultColor: keyof Theme = 'claude';
const defaultShimmerColor = 'claudeShimmer';
const defaultColor: keyof Theme = 'brand';
const defaultShimmerColor = 'brandShimmer';
const messageColor = overrideColor ?? defaultColor;
const shimmerColor = overrideShimmerColor ?? defaultShimmerColor;
@@ -0,0 +1,73 @@
import * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSettings } from '../../hooks/useSettings.js';
import { Box, Text } from '../../ink.js';
import { formatDuration } from '../../utils/format.js';
const FLASH_DURATION_MS = 1500;
// Sub-second turns don't get a flash — it reads as flicker, not feedback.
const MIN_TURN_MS = 1000;
type Props = {
/** True while a turn is in flight (isLoading). The flash fires on the
* true→false transition, NOT on spinner unmount — the spinner also hides
* mid-turn when streaming text takes over as the feedback. */
turnActive: boolean;
/** True when something else owns the spinner row or attention (brief idle
* status, permission prompts, dialogs, teammates still running). */
suppressed: boolean;
loadingStartTimeRef: React.RefObject<number>;
totalPausedMsRef: React.RefObject<number>;
};
/**
* Transient `✓ Done · 12s` row shown for ~1.5s after a response completes.
* Static text (no animation); same row footprint as the spinner (marginTop 1,
* single row) so the prompt below doesn't jump twice. Mount it permanently
* next to the spinner slot — it must observe the turnActive transition.
*/
export function CompletionFlash({
turnActive,
suppressed,
loadingStartTimeRef,
totalPausedMsRef
}: Props): React.ReactNode {
const settings = useSettings();
const reducedMotion = settings?.prefersReducedMotion === true;
const [flash, setFlash] = useState<{ durationMs: number } | null>(null);
const prevActiveRef = useRef(turnActive);
useEffect(() => {
const wasActive = prevActiveRef.current;
prevActiveRef.current = turnActive;
if (turnActive) {
// Next turn started — clear any lingering flash immediately.
setFlash(null);
return;
}
if (suppressed || reducedMotion) {
// Suppression owns the row/attention now: also hide an already-active
// flash rather than only preventing new ones.
setFlash(null);
return;
}
if (!wasActive) return;
const elapsedMs = Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
if (elapsedMs < MIN_TURN_MS) return;
setFlash({
durationMs: elapsedMs
});
const timer = setTimeout(() => setFlash(null), FLASH_DURATION_MS);
return () => clearTimeout(timer);
// refs are stable; only the transition matters
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, [turnActive, suppressed, reducedMotion]);
// Belt-and-braces with the effect's clear: don't render the one frame
// between suppression (or reduced motion) flipping on and the effect
// committing setFlash(null).
if (!flash || suppressed || reducedMotion) return null;
return <Box flexDirection="row" flexWrap="nowrap" marginTop={1} width="100%">
<Text color="success"> </Text>
<Text dimColor>Done · {formatDuration(flash.durationMs)}</Text>
</Box>;
}
+31 -6
View File
@@ -166,7 +166,7 @@ export function SpinnerAnimationRow({
// === Token count (leader + teammates, or foregrounded teammate) ===
const totalTokens = foregroundedTeammate && !foregroundedTeammate.isIdle ? foregroundedTeammate.progress?.tokenCount ?? 0 : leaderTokens + teammateTokens;
const tokenCount = formatNumber(totalTokens);
const tokensText = hasRunningTeammates ? `${tokenCount} tokens` : `${figures.arrowDown} ${tokenCount} tokens`;
const tokensText = `${tokenCount} tokens`;
const tokensWidth = stringWidth(tokensText);
// === Thinking text (may shrink to fit) ===
@@ -179,7 +179,9 @@ export function SpinnerAnimationRow({
// The extra 1 is a safety margin so content never touches the right edge.
const messageWidth = glimmerMessageWidth + 2;
const sep = SEP_WIDTH;
const parensWidth = 4;
// Non-teammate spins prepend the ↑/↓ mode glyph (width 2) + separator to
// the status parts, so reserve that space in the gating math too.
const parensWidth = hasRunningTeammates ? 4 : 4 + 2 + SEP_WIDTH;
const wantsThinking = thinkingStatus !== null;
const wantsTimerAndTokens = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS;
const availableSpace = columns - messageWidth - parensWidth;
@@ -195,6 +197,21 @@ export function SpinnerAnimationRow({
const showTimer = wantsTimerAndTokens && availableSpace > usedAfterThinking + timerWidth;
const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0);
const showTokens = wantsTimerAndTokens && totalTokens > 0 && availableSpace > usedAfterTimer + tokensWidth;
// Second chance for narrow terminals: the gating above reserves space for
// the mode glyph + separator, but a would-be thinking-only spin renders
// neither the glyph nor the wrapping parens beyond "( )". When nothing
// else will show, re-try the thinking gate with that space returned so
// "(thinking)" appears instead of nothing.
if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && !hasRunningTeammates && !spinnerSuffix && !showTimer && !showTokens) {
const bareAvailable = columns - messageWidth - 2;
if (bareAvailable > thinkingWidthValue) {
showThinking = true;
} else if (effortSuffix && bareAvailable > THINKING_BARE_WIDTH) {
thinkingText = 'thinking';
thinkingWidthValue = THINKING_BARE_WIDTH;
showThinking = true;
}
}
const thinkingOnly = showThinking && thinkingStatus === 'thinking' && !spinnerSuffix && !showTimer && !showTokens && true;
// === Thinking shimmer color (formerly ThinkingShimmerText's own timer) ===
@@ -209,14 +226,22 @@ export function SpinnerAnimationRow({
{spinnerSuffix}
</Text>] : []), ...(showTimer ? [<Text dimColor key="elapsedTime">
{timerText}
</Text>] : []), ...(showTokens ? [<Box flexDirection="row" key="tokens">
{!hasRunningTeammates && <SpinnerModeGlyph mode={mode} />}
<Text dimColor>{tokenCount} tokens</Text>
</Box>] : []), ...(showThinking && thinkingText ? [thinkingStatus === 'thinking' && !reducedMotion ? <Text key="thinking" color={thinkingShimmerColor}>
</Text>] : []), ...(showTokens ? [<Text dimColor key="tokens">
{tokensText}
</Text>] : []), ...(showThinking && thinkingText ? [thinkingStatus === 'thinking' && !reducedMotion ? <Text key="thinking" color={thinkingShimmerColor}>
{thinkingOnly ? `(${thinkingText})` : thinkingText}
</Text> : <Text dimColor key="thinking">
{thinkingText}
</Text>] : [])];
// Lead the status with the request-direction glyph (↑ requesting /
// ↓ responding) so the mode is visible whenever any status shows — it was
// previously buried inside the tokens part, which only appears after 30s.
// Skipped for thinkingOnly (kept minimal) and teammate spins (tree has it).
if (!hasRunningTeammates && !thinkingOnly && parts.length > 0) {
parts.unshift(<Box flexDirection="row" key="mode">
<SpinnerModeGlyph mode={mode} />
</Box>);
}
const status = foregroundedTeammate && !foregroundedTeammate.isIdle ? <>
<Text dimColor>(esc to interrupt </Text>
<Text color={toInkColor(foregroundedTeammate.identity.color)}>
+4 -6
View File
@@ -1,13 +1,11 @@
import type { RGBColor as RGBColorString } from '../../ink/styles.js'
import type { RGBColor as RGBColorType } from './types.js'
// Dot-pulse sweep. All width-1 chars from basic punctuation / Geometric
// Shapes with broad font support, so no per-terminal variants are needed
// (the old asterisk set required Ghostty/darwin special-casing for ✽/✳).
export function getDefaultCharacters(): string[] {
if (process.env.TERM === 'xterm-ghostty') {
return ['·', '✢', '✳', '✶', '✻', '*'] // Use * instead of ✽ for Ghostty because the latter renders in a way that's slightly offset
}
return process.platform === 'darwin'
? ['·', '✢', '✳', '✶', '✻', '✽']
: ['·', '✢', '*', '✶', '✻', '✽']
return ['·', '∘', '○', '◎', '◉', '●']
}
// Interpolate between two RGB colors
+17 -2
View File
@@ -19,6 +19,20 @@ export type LogoPalette = {
}
export const LOGO_PALETTES = {
ember: {
gradient: [
[255, 177, 95],
[255, 150, 60],
[255, 122, 26],
[230, 100, 10],
[190, 80, 8],
[145, 60, 8],
],
accent: [255, 122, 26],
cream: [230, 200, 170],
dim: [130, 100, 75],
border: [105, 80, 60],
},
sunset: {
gradient: [
[255, 180, 100],
@@ -81,10 +95,11 @@ export type LogoPaletteName = keyof typeof LOGO_PALETTES
export const LOGO_PALETTE_NAMES = Object.keys(LOGO_PALETTES) as LogoPaletteName[]
export const DEFAULT_LOGO_PALETTE: LogoPaletteName = 'sunset'
export const DEFAULT_LOGO_PALETTE: LogoPaletteName = 'ember'
export const LOGO_PALETTE_LABELS: Record<LogoPaletteName, string> = {
sunset: 'Sunset (default)',
ember: 'Ember (default)',
sunset: 'Sunset',
forest: 'Forest green',
ocean: 'Ocean blue',
monochrome: 'Monochrome',
+2 -1
View File
@@ -15,6 +15,7 @@ import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscover
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { parseUserSpecifiedModel } from '../utils/model/model.js'
import { DEFAULT_GEMINI_MODEL } from '../utils/providerProfile.js'
import { BRAND_TAGLINE } from '../constants/brand.js'
import { getGlobalConfig } from '../utils/config.js'
import { ANSI_DIM, ANSI_RESET, ansiRgb } from '../utils/terminalAnsi.js'
import {
@@ -209,7 +210,7 @@ export function printStartupScreen(modelOverride?: string): void {
out.push('')
// Tagline
out.push(` ${ansiRgb(...ACCENT)}\u2726${RESET} ${ansiRgb(...CREAM)}Any model. Every tool. Zero limits.${RESET} ${ansiRgb(...ACCENT)}\u2726${RESET}`)
out.push(` ${ansiRgb(...ACCENT)}\u2726${RESET} ${ansiRgb(...CREAM)}${BRAND_TAGLINE}${RESET} ${ansiRgb(...ACCENT)}\u2726${RESET}`)
out.push('')
// Provider info box
+4 -2
View File
@@ -407,9 +407,11 @@ function formatDiff(lines: string[], startingLineNumber: number, width: number,
// so the visual continuity (solid red/green bar) is unchanged.
return <Box key={key} flexDirection="row">
<NoSelect fromLeftEdge>
<Text color={overrideTheme ? 'text' : undefined} backgroundColor={bgColor} dimColor={dim || type === 'nochange'}>
{/* Line numbers always dim (mirrors color-diff addLineNumber) so
the gutter recedes; the +/- sigil keeps full intensity. */}
<Text color={overrideTheme ? 'text' : undefined} backgroundColor={bgColor} dimColor={true}>
{lineNumStr}
{sigil}
<Text dimColor={dim || type === 'nochange'}>{sigil}</Text>
</Text>
</NoSelect>
<Text color={overrideTheme ? 'text' : undefined} backgroundColor={bgColor} dimColor={dim}>
+1 -1
View File
@@ -224,7 +224,7 @@ export function WorktreeExitDialog({
description: removeDescription
}];
const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep';
return <Dialog title="Exiting worktree session" subtitle={subtitle} onCancel={handleCancel}>
return <Dialog title="Exiting worktree session" subtitle={subtitle} onCancel={handleCancel} showNavigationHint={true}>
<Select defaultFocusValue={defaultValue} options={options} onChange={handleSelect} />
</Dialog>;
}
+38 -104
View File
@@ -1,4 +1,3 @@
import { c as _c } from "react-compiler-runtime";
import React from 'react';
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import { Box, Text } from '../../ink.js';
@@ -19,6 +18,9 @@ type DialogProps = {
hideBorder?: boolean;
/** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */
inputGuide?: (exitState: ExitState) => React.ReactNode;
/** Prepend "↑/↓ navigate" to the default input guide. Opt in from dialogs
* that host a Select so the navigation affordance is discoverable. */
showNavigationHint?: boolean;
/**
* Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt
* (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text
@@ -28,111 +30,43 @@ type DialogProps = {
*/
isCancelActive?: boolean;
};
export function Dialog(t0) {
const $ = _c(27);
const {
title,
subtitle,
children,
onCancel,
color: t1,
hideInputGuide,
hideBorder,
inputGuide,
isCancelActive: t2
} = t0;
const color = t1 === undefined ? "permission" : t1;
const isCancelActive = t2 === undefined ? true : t2;
// Plain React (not react-compiler output): dialogs render rarely, and the
// memo-slot bookkeeping isn't worth maintaining here.
export function Dialog({
title,
subtitle,
children,
onCancel,
color = 'permission',
hideInputGuide,
hideBorder,
inputGuide,
showNavigationHint,
isCancelActive = true
}: DialogProps): React.ReactNode {
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
let t3;
if ($[0] !== isCancelActive) {
t3 = {
context: "Confirmation",
isActive: isCancelActive
};
$[0] = isCancelActive;
$[1] = t3;
} else {
t3 = $[1];
}
useKeybinding("confirm:no", onCancel, t3);
let t4;
if ($[2] !== exitState.keyName || $[3] !== exitState.pending) {
t4 = exitState.pending ? <Text>Press {exitState.keyName} again to exit</Text> : <Byline><KeyboardShortcutHint shortcut="Enter" action="confirm" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline>;
$[2] = exitState.keyName;
$[3] = exitState.pending;
$[4] = t4;
} else {
t4 = $[4];
}
const defaultInputGuide = t4;
let t5;
if ($[5] !== color || $[6] !== title) {
t5 = <Text bold={true} color={color}>{title}</Text>;
$[5] = color;
$[6] = title;
$[7] = t5;
} else {
t5 = $[7];
}
let t6;
if ($[8] !== subtitle) {
t6 = subtitle && <Text dimColor={true}>{subtitle}</Text>;
$[8] = subtitle;
$[9] = t6;
} else {
t6 = $[9];
}
let t7;
if ($[10] !== t5 || $[11] !== t6) {
t7 = <Box flexDirection="column">{t5}{t6}</Box>;
$[10] = t5;
$[11] = t6;
$[12] = t7;
} else {
t7 = $[12];
}
let t8;
if ($[13] !== children || $[14] !== t7) {
t8 = <Box flexDirection="column" gap={1}>{t7}{children}</Box>;
$[13] = children;
$[14] = t7;
$[15] = t8;
} else {
t8 = $[15];
}
let t9;
if ($[16] !== defaultInputGuide || $[17] !== exitState || $[18] !== hideInputGuide || $[19] !== inputGuide) {
t9 = !hideInputGuide && <Box marginTop={1}><FullWidthRow><Text dimColor={true} italic={true}>{inputGuide ? inputGuide(exitState) : defaultInputGuide}</Text></FullWidthRow></Box>;
$[16] = defaultInputGuide;
$[17] = exitState;
$[18] = hideInputGuide;
$[19] = inputGuide;
$[20] = t9;
} else {
t9 = $[20];
}
let t10;
if ($[21] !== t8 || $[22] !== t9) {
t10 = <>{t8}{t9}</>;
$[21] = t8;
$[22] = t9;
$[23] = t10;
} else {
t10 = $[23];
}
const content = t10;
useKeybinding('confirm:no', onCancel, {
context: 'Confirmation',
isActive: isCancelActive
});
const defaultInputGuide = exitState.pending ? <Text>Press {exitState.keyName} again to exit</Text> : <Byline>
{showNavigationHint && <KeyboardShortcutHint shortcut="↑/↓" action="navigate" />}
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
{isCancelActive && <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />}
</Byline>;
const content = <>
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold={true} color={color}>{title}</Text>
{subtitle && <Text dimColor={true}>{subtitle}</Text>}
</Box>
{children}
</Box>
{!hideInputGuide && <Box marginTop={1}><FullWidthRow><Text dimColor={true} italic={true}>{inputGuide ? inputGuide(exitState) : defaultInputGuide}</Text></FullWidthRow></Box>}
</>;
if (hideBorder) {
return content;
}
let t11;
if ($[24] !== color || $[25] !== content) {
t11 = <Pane color={color}>{content}</Pane>;
$[24] = color;
$[25] = content;
$[26] = t11;
} else {
t11 = $[26];
}
return t11;
return <Pane color={color}>{content}</Pane>;
}
+5 -3
View File
@@ -22,8 +22,10 @@ type Props<T> = {
initialQuery?: string;
items: readonly T[];
getKey: (item: T) => string;
/** Keep to one line — preview handles overflow. */
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
/** Keep to one line preview handles overflow. `query` is the live search
* text, passed as an argument (not closed over) so memoized render
* callbacks can highlight matches without going stale. */
renderItem: (item: T, isFocused: boolean, query: string) => React.ReactNode;
renderPreview?: (item: T) => React.ReactNode;
/** 'right' keeps hints stable (no bounce), but needs width. */
previewPosition?: 'bottom' | 'right';
@@ -170,7 +172,7 @@ export function FuzzyPicker<T>({
const visible = items.slice(windowStart, windowStart + visibleCount);
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
const searchBox = <SearchBox query={query} cursorOffset={cursorOffset} placeholder={placeholder} isFocused isTerminalFocused={isTerminalFocused} />;
const listBlock = <List visible={visible} windowStart={windowStart} visibleCount={visibleCount} total={items.length} focusedIndex={focusedIndex} direction={direction} getKey={getKey} renderItem={renderItem} emptyText={emptyText} />;
const listBlock = <List visible={visible} windowStart={windowStart} visibleCount={visibleCount} total={items.length} focusedIndex={focusedIndex} direction={direction} getKey={getKey} renderItem={(item: T, isFocused: boolean) => renderItem(item, isFocused, query)} emptyText={emptyText} />;
const preview = renderPreview && focused ? <Box flexDirection="column" flexGrow={1}>
{renderPreview(focused)}
</Box> : null;
+37
View File
@@ -0,0 +1,37 @@
/**
* OpenClaude brand identity single source of truth for the product name,
* tagline, accent color, and wordmark art used across the TUI.
*
* The accent is the gitlawb orange. Theme entries derived from it MUST stay
* in `rgb(r,g,b)` form (never hex): the spinner's shimmer/stall interpolation
* parses theme values with `parseRGB`, which only matches `rgb(...)` strings.
*/
export const BRAND_NAME = 'OpenClaude'
export const BRAND_TAGLINE = 'Open terminal for any LLM'
/** gitlawb orange (#ff7a1a) in the rgb() form required by theme consumers. */
export const BRAND_ACCENT_RGB = 'rgb(255,122,26)'
/**
* Two-row Unicode half-block wordmark, split so the two halves can be
* rendered in different accent shades. Block characters ( ) render
* correctly in Apple Terminal. Rendered side by side with a 1-col gap:
*
*
*
*/
export const WORDMARK_OPEN = [
'█▀█ █▀█ █▀▀ █▄ █',
'█▄█ █▀▀ ██▄ █ ▀█',
] as const
export const WORDMARK_CLAUDE = [
'█▀▀ █ ▄▀█ █ █ █▀▄ █▀▀',
'█▄▄ █▄▄ █▀█ █▄█ █▄▀ ██▄',
] as const
/** Rendered width of the full wordmark: open half + 1-col gap + claude half. */
export const WORDMARK_WIDTH =
WORDMARK_OPEN[0].length + 1 + WORDMARK_CLAUDE[0].length
+3 -1
View File
@@ -3,7 +3,9 @@ import { env } from '../utils/env.js'
// The former is better vertically aligned, but isn't usually supported on Windows/Linux
export const BLACK_CIRCLE = env.platform === 'darwin' ? '⏺' : '●'
export const BULLET_OPERATOR = '∙'
export const TEARDROP_ASTERISK = '✻'
// Historically '✻' (hence the name); now '◎' to match the dot-pulse spinner
// glyph family (· ∘ ○ ◎ ◉ ●) introduced with the OpenClaude rebrand.
export const TEARDROP_ASTERISK = '◎'
export const UP_ARROW = '\u2191' // ↑ - used for opus 1m merge notice
export const DOWN_ARROW = '\u2193' // ↓ - used for scroll hint
export const LIGHTNING_BOLT = '↯' // \u21af - used for fast mode indicator
+5 -2
View File
@@ -733,13 +733,16 @@ function addLineNumber(
foreground: h.marker ? decorationColor(h.marker, theme) : theme.foreground,
background: h.marker ? lineBackground(h.marker, theme) : theme.background,
}
const shouldDim = h.marker === null || h.marker === ' '
// Dim every line number, not just context lines: on +/- lines the gutter
// used to render at full decoration intensity and competed with the content
// for attention (worst on light themes). fullDim lines are dimmed wholesale
// by dimContent, so the prefix needs no extra wrapping there.
for (let i = 0; i < h.lines.length; i++) {
const prefix =
i === 0
? ` ${String(h.lineNumber).padStart(maxDigits)} `
: ' '.repeat(maxDigits + 2)
const wrapped = shouldDim && !fullDim ? `${DIM}${prefix}${UNDIM}` : prefix
const wrapped = fullDim ? prefix : `${DIM}${prefix}${UNDIM}`
h.lines[i]!.unshift([style, wrapped])
}
}
+5
View File
@@ -66,6 +66,7 @@ import { useSSHSession } from '../hooks/useSSHSession.js';
import { useAssistantHistory } from '../hooks/useAssistantHistory.js';
import type { SSHSession } from '../ssh/createSSHSession.js';
import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js';
import { CompletionFlash } from '../components/Spinner/CompletionFlash.js';
import { getSystemPrompt } from '../constants/prompts.js';
import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js';
import { getSystemContext, getUserContext } from '../context.js';
@@ -4582,6 +4583,10 @@ export function REPL({
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
<Box flexGrow={1} />
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
{/* Permanently mounted: it observes the isLoading transition to flash
`✓ Done` for ~1.5s. Suppressed wherever another element owns the
row or the user's attention. */}
<CompletionFlash turnActive={isLoading || userInputOnProcessing !== undefined} suppressed={isBriefOnly || hasRunningTeammates || hasActivePrompt || viewedAgentTask !== undefined} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} />
{!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && <BriefIdleStatus />}
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
</>} bottom={<Box flexDirection={isBuddyEnabled() && companionNarrow ? 'column' : 'row'} width="100%" alignItems={isBuddyEnabled() && companionNarrow ? undefined : 'flex-end'}>
+5
View File
@@ -595,6 +595,9 @@ export type GlobalConfig = {
// PR status footer configuration (feature-flagged via GrowthBook)
prStatusFooterEnabled?: boolean // Show PR review status in footer (default: true)
// Built-in status bar shown when no custom statusLine command is configured (default: true)
defaultStatusLineEnabled?: boolean
// Tmux live panel visibility (internal-only, toggled via Enter on tmux pill)
tungstenPanelVisible?: boolean
@@ -708,6 +711,7 @@ function createDefaultGlobalConfig(): GlobalConfig {
autoInstallIdeExtension: true,
fileCheckpointingEnabled: true,
terminalProgressBarEnabled: true,
defaultStatusLineEnabled: true,
cachedStatsigGates: {},
cachedDynamicConfigs: {},
cachedGrowthBookFeatures: {},
@@ -764,6 +768,7 @@ export const GLOBAL_CONFIG_KEYS = [
'flickerFreeMode',
'permissionExplainerEnabled',
'prStatusFooterEnabled',
'defaultStatusLineEnabled',
'remoteControlAtStartup',
'remoteDialogSeen',
'knowledgeGraphEnabled',
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'bun:test'
import { stripVTControlCharacters } from 'node:util'
import * as React from 'react'
import { Text } from '../ink.js'
import { highlightFuzzyMatch } from './highlightMatch.js'
import { renderToString } from './staticRender.js'
/** Collects the string contents of every `<Text bold>` element in the tree. */
function boldSegments(node: React.ReactNode): string[] {
const out: string[] = []
const walk = (n: React.ReactNode): void => {
if (n === null || n === undefined || typeof n === 'string' || typeof n === 'number') {
return
}
if (Array.isArray(n)) {
n.forEach(walk)
return
}
if (React.isValidElement(n)) {
const props = n.props as { bold?: boolean; children?: React.ReactNode }
if (props.bold === true) {
out.push(React.Children.toArray(props.children).join(''))
return
}
walk(props.children)
}
}
walk(node)
return out
}
describe('highlightFuzzyMatch', () => {
it('returns the text unchanged for an empty query', () => {
expect(highlightFuzzyMatch('src/main.ts', '')).toBe('src/main.ts')
})
it('returns the text unchanged when the query is not a subsequence', () => {
expect(highlightFuzzyMatch('src/main.ts', 'zzz')).toBe('src/main.ts')
})
it('preserves the visible text exactly', async () => {
const out = await renderToString(
<Text>{highlightFuzzyMatch('src/components/Spinner.tsx', 'spin')}</Text>,
80,
)
expect(stripVTControlCharacters(out).trimEnd()).toBe(
'src/components/Spinner.tsx',
)
})
it('bolds a contiguous match case-insensitively', () => {
const node = highlightFuzzyMatch('src/components/Spinner.tsx', 'spin')
expect(boldSegments(node)).toEqual(['Spin'])
})
it('bolds scattered subsequence characters', () => {
// 'sct' is not contiguous in 'select.tsx' but is a subsequence:
// greedy-earliest matches s(0), c(4), t(5) → runs 's' and 'ct'
const node = highlightFuzzyMatch('select.tsx', 'sct')
expect(boldSegments(node)).toEqual(['s', 'ct'])
})
it('coalesces adjacent matched characters into one run', () => {
// 'mai' in 'main.ts' is contiguous via the subsequence path too;
// force the subsequence path with a query spanning a gap
const node = highlightFuzzyMatch('main.ts', 'aints')
expect(boldSegments(node)).toEqual(['ain', 'ts'])
})
})
+42
View File
@@ -25,3 +25,45 @@ export function highlightMatch(text: string, query: string): React.ReactNode {
if (offset < text.length) parts.push(text.slice(offset));
return <>{parts}</>;
}
/**
* Bold the characters of `text` that a fuzzy query matched (case-insensitive).
* Prefers a contiguous run; otherwise replays the greedy earliest-subsequence
* scan the fuzzy matchers use. Bold (not inverse) so a focused row's
* suggestion color stays visible. Returns `text` unchanged when the query
* isn't a subsequence (e.g. the row matched on a different field).
*/
export function highlightFuzzyMatch(text: string, query: string): React.ReactNode {
if (!query) return text;
const textLower = text.toLowerCase();
const queryLower = query.toLowerCase();
const idx = textLower.indexOf(queryLower);
if (idx !== -1) {
return <>
{text.slice(0, idx)}
<Text bold>{text.slice(idx, idx + query.length)}</Text>
{text.slice(idx + query.length)}
</>;
}
const matched: boolean[] = new Array(text.length).fill(false);
let qi = 0;
for (let ti = 0; ti < text.length && qi < queryLower.length; ti++) {
if (textLower[ti] === queryLower[qi]) {
matched[ti] = true;
qi++;
}
}
if (qi < queryLower.length) return text;
const parts: React.ReactNode[] = [];
let runStart = 0;
for (let i = 1; i <= text.length; i++) {
if (i === text.length || matched[i] !== matched[runStart]) {
const segment = text.slice(runStart, i);
parts.push(matched[runStart] ? <Text key={runStart} bold>
{segment}
</Text> : segment);
runStart = i;
}
}
return <>{parts}</>;
}
+33 -16
View File
@@ -6,6 +6,11 @@ export type Theme = {
bashBorder: string
claude: string
claudeShimmer: string // Lighter version of claude color for shimmer effect
// OpenClaude brand accent (gitlawb orange). Values MUST be rgb() strings in
// truecolor themes — spinner shimmer/stall interpolation parses them with
// parseRGB, which silently fails on hex.
brand: string
brandShimmer: string // Lighter version of brand color for shimmer effect
claudeBlue_FOR_SYSTEM_SPINNER: string
claudeBlueShimmer_FOR_SYSTEM_SPINNER: string
permission: string
@@ -115,8 +120,10 @@ 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: 'rgb(209,87,0)', // Brand orange darkened for contrast on white
claudeShimmer: 'rgb(255,122,26)', // Full brand orange as the shimmer highlight
brand: 'rgb(209,87,0)', // Brand orange darkened for contrast on white
brandShimmer: 'rgb(255,122,26)', // Full brand orange as the shimmer highlight
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
@@ -158,7 +165,7 @@ const lightTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(209,87,0)', // Brand orange (light variant)
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 +180,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: 'rgb(209,87,0)', // Brand orange (light variant, matches claude)
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',
@@ -199,6 +206,8 @@ const lightAnsiTheme: Theme = {
bashBorder: 'ansi:magenta',
claude: 'ansi:redBright',
claudeShimmer: 'ansi:yellowBright',
brand: 'ansi:redBright', // No orange in the 16-color palette; nearest warm hue
brandShimmer: 'ansi:yellowBright',
claudeBlue_FOR_SYSTEM_SPINNER: 'ansi:blue',
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'ansi:blueBright',
permission: 'ansi:blue',
@@ -280,6 +289,8 @@ const darkAnsiTheme: Theme = {
bashBorder: 'ansi:magentaBright',
claude: 'ansi:redBright',
claudeShimmer: 'ansi:yellowBright',
brand: 'ansi:redBright', // No orange in the 16-color palette; nearest warm hue
brandShimmer: 'ansi:yellowBright',
claudeBlue_FOR_SYSTEM_SPINNER: 'ansi:blueBright',
claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'ansi:blueBright',
permission: 'ansi:blueBright',
@@ -359,8 +370,10 @@ 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: 'rgb(200,100,0)', // Brand orange darkened for white bg, luminance-separated from warning
claudeShimmer: 'rgb(255,150,40)', // Lighter brand orange for shimmer effect
brand: 'rgb(200,100,0)', // Brand orange darkened for white bg, luminance-separated from warning
brandShimmer: 'rgb(255,150,40)', // Lighter brand orange for shimmer effect
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
@@ -402,7 +415,7 @@ const lightDaltonizedTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(200,100,0)', // Brand orange (light daltonized variant)
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 +429,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: 'rgb(200,100,0)', // Brand orange adjusted for deuteranopia (matches claude)
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',
@@ -440,8 +453,10 @@ 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
claude: 'rgb(255,122,26)', // OpenClaude brand orange
claudeShimmer: 'rgb(255,177,95)', // Lighter brand orange for shimmer effect
brand: 'rgb(255,122,26)', // OpenClaude brand orange
brandShimmer: 'rgb(255,177,95)', // Lighter brand 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
@@ -483,7 +498,7 @@ const darkTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(255,122,26)', // Brand orange
clawd_background: 'rgb(0,0,0)',
userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast
userMessageBackgroundHover: 'rgb(70, 70, 70)',
@@ -497,7 +512,7 @@ const darkTheme: Theme = {
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: 'rgb(255,122,26)', // Brand orange (matches claude)
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',
@@ -521,8 +536,10 @@ 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: 'rgb(255,150,40)', // Brand orange adjusted for deuteranopia (luminance-separated from error/warning)
claudeShimmer: 'rgb(255,195,115)', // Lighter brand orange for shimmer effect
brand: 'rgb(255,150,40)', // Brand orange adjusted for deuteranopia (luminance-separated from error/warning)
brandShimmer: 'rgb(255,195,115)', // Lighter brand orange for shimmer effect
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
@@ -564,7 +581,7 @@ const darkDaltonizedTheme: Theme = {
// Chrome colors
chromeYellow: 'rgb(251,188,4)', // Chrome yellow
// TUI V2 colors
clawd_body: 'rgb(215,119,87)',
clawd_body: 'rgb(255,150,40)', // Brand orange (dark daltonized variant)
clawd_background: 'rgb(0,0,0)',
userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast
userMessageBackgroundHover: 'rgb(70, 70, 70)',
@@ -578,7 +595,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: 'rgb(255,150,40)', // Brand orange adjusted for deuteranopia (matches claude)
rainbow_red: 'rgb(235,95,87)',
rainbow_orange: 'rgb(245,139,87)',
rainbow_yellow: 'rgb(250,195,95)',