Skip to content

Commit f749332

Browse files
unraidclaude
andcommitted
feat: add built-in status line with usage quota, token counts, and cost display
Adds a default built-in status line that shows model name, context usage with token counts, 5h session and 7d weekly rate limit progress bars with countdowns, and cumulative cost. No configuration needed — displays automatically when no external statusLine command is configured. Refactors StatusLine.tsx into routing component with ExternalStatusLine (existing) and BuiltinStatusLineWrapper (new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3b0a5e4 commit f749332

3 files changed

Lines changed: 238 additions & 1 deletion

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { formatCost } from '../cost-tracker.js';
3+
import { Box, Text } from '../ink.js';
4+
import { formatTokens } from '../utils/format.js';
5+
import { ProgressBar } from './design-system/ProgressBar.js';
6+
import { useTerminalSize } from '../hooks/useTerminalSize.js';
7+
8+
type RateLimitBucket = {
9+
utilization: number;
10+
resets_at: number;
11+
};
12+
13+
type BuiltinStatusLineProps = {
14+
modelName: string;
15+
contextUsedPct: number;
16+
usedTokens: number;
17+
contextWindowSize: number;
18+
totalCostUsd: number;
19+
rateLimits: {
20+
five_hour?: RateLimitBucket;
21+
seven_day?: RateLimitBucket;
22+
};
23+
};
24+
25+
/**
26+
* Format a countdown from now until the given epoch time (in seconds).
27+
* Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now".
28+
*/
29+
export function formatCountdown(epochSeconds: number): string {
30+
const diff = epochSeconds - Date.now() / 1000;
31+
if (diff <= 0) return 'now';
32+
33+
const days = Math.floor(diff / 86400);
34+
const hours = Math.floor((diff % 86400) / 3600);
35+
const minutes = Math.floor((diff % 3600) / 60);
36+
37+
if (days >= 1) return `${days}d${hours}h`;
38+
if (hours >= 1) return `${hours}h${minutes}m`;
39+
return `${minutes}m`;
40+
}
41+
42+
function Separator() {
43+
return <Text dimColor>{' \u2502 '}</Text>;
44+
}
45+
46+
function BuiltinStatusLineInner({
47+
modelName,
48+
contextUsedPct,
49+
usedTokens,
50+
contextWindowSize,
51+
totalCostUsd,
52+
rateLimits,
53+
}: BuiltinStatusLineProps) {
54+
const { columns } = useTerminalSize();
55+
56+
// Force re-render every 60s so countdowns stay current
57+
const [tick, setTick] = useState(0);
58+
useEffect(() => {
59+
const hasResetTime = rateLimits.five_hour?.resets_at || rateLimits.seven_day?.resets_at;
60+
if (!hasResetTime) return;
61+
const id = setInterval(() => setTick(t => t + 1), 60_000);
62+
return () => clearInterval(id);
63+
}, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]);
64+
65+
// Suppress unused-variable lint for tick (it exists only to trigger re-renders)
66+
void tick;
67+
68+
// Model display: use first two words (e.g. "Opus 4.6") instead of just first word
69+
const modelParts = modelName.split(' ');
70+
const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName;
71+
72+
const wide = columns >= 100;
73+
const narrow = columns < 60;
74+
75+
const hasFiveHour = rateLimits.five_hour != null;
76+
const hasSevenDay = rateLimits.seven_day != null;
77+
78+
const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0;
79+
const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0;
80+
81+
// Token display: "50k/1M"
82+
const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`;
83+
84+
return (
85+
<Box wrap="truncate">
86+
{/* Model name */}
87+
<Text>{shortModel}</Text>
88+
89+
{/* Context usage with token counts */}
90+
<Separator />
91+
<Text dimColor>Context </Text>
92+
<Text>{contextUsedPct}%</Text>
93+
{!narrow && <Text dimColor> ({tokenDisplay})</Text>}
94+
95+
{/* 5-hour session rate limit */}
96+
{hasFiveHour && (
97+
<>
98+
<Separator />
99+
<Text dimColor>Session </Text>
100+
{wide && (
101+
<>
102+
<ProgressBar
103+
ratio={rateLimits.five_hour!.utilization}
104+
width={10}
105+
fillColor="rate_limit_fill"
106+
emptyColor="rate_limit_empty"
107+
/>
108+
<Text> </Text>
109+
</>
110+
)}
111+
<Text>{fiveHourPct}%</Text>
112+
{!narrow && rateLimits.five_hour!.resets_at > 0 && (
113+
<Text dimColor> {formatCountdown(rateLimits.five_hour!.resets_at)}</Text>
114+
)}
115+
</>
116+
)}
117+
118+
{/* 7-day weekly rate limit */}
119+
{hasSevenDay && (
120+
<>
121+
<Separator />
122+
<Text dimColor>Weekly </Text>
123+
{wide && (
124+
<>
125+
<ProgressBar
126+
ratio={rateLimits.seven_day!.utilization}
127+
width={10}
128+
fillColor="rate_limit_fill"
129+
emptyColor="rate_limit_empty"
130+
/>
131+
<Text> </Text>
132+
</>
133+
)}
134+
<Text>{sevenDayPct}%</Text>
135+
{!narrow && rateLimits.seven_day!.resets_at > 0 && (
136+
<Text dimColor> {formatCountdown(rateLimits.seven_day!.resets_at)}</Text>
137+
)}
138+
</>
139+
)}
140+
141+
{/* Cost */}
142+
{totalCostUsd > 0 && (
143+
<>
144+
<Separator />
145+
<Text>{formatCost(totalCostUsd)}</Text>
146+
</>
147+
)}
148+
</Box>
149+
);
150+
}
151+
152+
export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);

src/components/StatusLine.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ import { getRuntimeMainLoopModel, type ModelName, renderModelName } from '../uti
2626
import { getCurrentSessionTitle } from '../utils/sessionStorage.js';
2727
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
2828
import { getCurrentWorktreeSession } from '../utils/worktree.js';
29+
import { BuiltinStatusLine } from './BuiltinStatusLine.js';
2930
import { isVimModeEnabled } from './PromptInput/utils.js';
3031
export function statusLineShouldDisplay(settings: ReadonlySettings): boolean {
3132
// Assistant mode: statusline fields (model, permission mode, cwd) reflect the
3233
// REPL/daemon process, not what the agent child is actually running. Hide it.
3334
if (feature('KAIROS') && getKairosActive()) return false;
34-
return settings?.statusLine !== undefined;
35+
return true;
3536
}
3637
function buildStatusLineCommandInput(permissionMode: PermissionMode, exceeds200kTokens: boolean, settings: ReadonlySettings, messages: Message[], addedDirs: string[], mainLoopModel: ModelName, vimMode?: VimMode): StatusLineCommandInput {
3738
const agentType = getMainThreadAgentType();
@@ -139,6 +140,60 @@ function StatusLineInner({
139140
messagesRef,
140141
lastAssistantMessageId,
141142
vimMode
143+
}: Props): React.ReactNode {
144+
const settings = useSettings();
145+
146+
if (settings?.statusLine) {
147+
// External command path — all existing logic lives in ExternalStatusLine
148+
return <ExternalStatusLine
149+
messagesRef={messagesRef}
150+
lastAssistantMessageId={lastAssistantMessageId}
151+
vimMode={vimMode}
152+
/>;
153+
}
154+
155+
// Built-in path
156+
return <BuiltinStatusLineWrapper
157+
messagesRef={messagesRef}
158+
lastAssistantMessageId={lastAssistantMessageId}
159+
/>;
160+
}
161+
162+
function BuiltinStatusLineWrapper({ messagesRef, lastAssistantMessageId }: {
163+
messagesRef: React.RefObject<Message[]>;
164+
lastAssistantMessageId: string | null;
165+
}): React.ReactNode {
166+
const mainLoopModel = useMainLoopModel();
167+
const permissionMode = useAppState(s => s.toolPermissionContext.mode);
168+
169+
// Compute exceeds200k only when message changes
170+
const exceeds200kTokens = lastAssistantMessageId
171+
? doesMostRecentAssistantMessageExceed200k(messagesRef.current)
172+
: false;
173+
174+
const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel, exceeds200kTokens });
175+
const modelDisplay = renderModelName(runtimeModel);
176+
const currentUsage = getCurrentUsage(messagesRef.current);
177+
const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas());
178+
const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize);
179+
const rawUtil = getRawUtilization();
180+
const totalCost = getTotalCost();
181+
const usedTokens = getTotalInputTokens() + getTotalOutputTokens();
182+
183+
return <BuiltinStatusLine
184+
modelName={modelDisplay}
185+
contextUsedPct={contextPercentages.used}
186+
usedTokens={usedTokens}
187+
contextWindowSize={contextWindowSize}
188+
totalCostUsd={totalCost}
189+
rateLimits={rawUtil}
190+
/>;
191+
}
192+
193+
function ExternalStatusLine({
194+
messagesRef,
195+
lastAssistantMessageId,
196+
vimMode
142197
}: Props): React.ReactNode {
143198
const abortControllerRef = useRef<AbortController | undefined>(undefined);
144199
const permissionMode = useAppState(s => s.toolPermissionContext.mode);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { formatCountdown } from '../BuiltinStatusLine.js'
3+
4+
describe('formatCountdown', () => {
5+
const now = Date.now() / 1000
6+
7+
test('returns "now" for past time', () => {
8+
expect(formatCountdown(now - 60)).toBe('now')
9+
})
10+
11+
test('returns "now" for exactly zero diff', () => {
12+
expect(formatCountdown(now)).toBe('now')
13+
})
14+
15+
test('returns minutes for less than 1 hour', () => {
16+
expect(formatCountdown(now + 45 * 60)).toBe('45m')
17+
})
18+
19+
test('returns hours and minutes for less than 1 day', () => {
20+
expect(formatCountdown(now + 3 * 3600 + 12 * 60)).toBe('3h12m')
21+
})
22+
23+
test('returns hours with 0 minutes', () => {
24+
expect(formatCountdown(now + 1 * 3600)).toBe('1h0m')
25+
})
26+
27+
test('returns days and hours for 1+ days', () => {
28+
expect(formatCountdown(now + 5 * 86400 + 20 * 3600)).toBe('5d20h')
29+
})
30+
})

0 commit comments

Comments
 (0)