Repository navigation
UI/upgraded UI #1605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
UI/upgraded UI #1605
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
85c43f7
feat(ui): OpenClaude brand identity — orange accent, wordmark, dot-pu…
kevincodex1 359c867
feat(ui): built-in default statusline
kevincodex1 0319d50
feat(ui): polish pass — fuzzy match highlighting, completion flash, d…
kevincodex1 130b320
fix(ui): address review — explicit statusline default, narrow-termina…
kevincodex1 8c6a7f2
fix(ui): address review round 2 — hint accuracy, flash suppression, c…
kevincodex1 99e45d4
fix(ui): drop fit-budget floor, cover reduced-motion render gap
kevincodex1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| /** 0–100, 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.