Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions src/components/BuiltinStatusLine.test.tsx
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)
})
})
Comment thread
kevincodex1 marked this conversation as resolved.
183 changes: 183 additions & 0 deletions src/components/BuiltinStatusLine.tsx
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);
2 changes: 1 addition & 1 deletion src/components/CustomSelect/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/DevChannelsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/components/HistorySearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion src/components/IdleReturnDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading