diff --git a/src/components/BuiltinStatusLine.tsx b/src/components/BuiltinStatusLine.tsx
new file mode 100644
index 00000000..14b7ec59
--- /dev/null
+++ b/src/components/BuiltinStatusLine.tsx
@@ -0,0 +1,152 @@
+import React, { useEffect, useState } from 'react';
+import { formatCost } from '../cost-tracker.js';
+import { Box, Text } from '../ink.js';
+import { formatTokens } from '../utils/format.js';
+import { ProgressBar } from './design-system/ProgressBar.js';
+import { useTerminalSize } from '../hooks/useTerminalSize.js';
+
+type RateLimitBucket = {
+ utilization: number;
+ resets_at: number;
+};
+
+type BuiltinStatusLineProps = {
+ modelName: string;
+ contextUsedPct: number;
+ usedTokens: number;
+ contextWindowSize: number;
+ totalCostUsd: number;
+ rateLimits: {
+ five_hour?: RateLimitBucket;
+ seven_day?: RateLimitBucket;
+ };
+};
+
+/**
+ * Format a countdown from now until the given epoch time (in seconds).
+ * Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now".
+ */
+export function formatCountdown(epochSeconds: number): string {
+ const diff = epochSeconds - Date.now() / 1000;
+ if (diff <= 0) return 'now';
+
+ const days = Math.floor(diff / 86400);
+ const hours = Math.floor((diff % 86400) / 3600);
+ const minutes = Math.floor((diff % 3600) / 60);
+
+ if (days >= 1) return `${days}d${hours}h`;
+ if (hours >= 1) return `${hours}h${minutes}m`;
+ return `${minutes}m`;
+}
+
+function Separator() {
+ return {' \u2502 '};
+}
+
+function BuiltinStatusLineInner({
+ modelName,
+ contextUsedPct,
+ usedTokens,
+ contextWindowSize,
+ totalCostUsd,
+ rateLimits,
+}: BuiltinStatusLineProps) {
+ const { columns } = useTerminalSize();
+
+ // Force re-render every 60s so countdowns stay current
+ const [tick, setTick] = useState(0);
+ useEffect(() => {
+ const hasResetTime = rateLimits.five_hour?.resets_at || rateLimits.seven_day?.resets_at;
+ if (!hasResetTime) return;
+ const id = setInterval(() => setTick(t => t + 1), 60_000);
+ return () => clearInterval(id);
+ }, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]);
+
+ // Suppress unused-variable lint for tick (it exists only to trigger re-renders)
+ void tick;
+
+ // Model display: use first two words (e.g. "Opus 4.6") instead of just first word
+ const modelParts = modelName.split(' ');
+ const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName;
+
+ const wide = columns >= 100;
+ const narrow = columns < 60;
+
+ const hasFiveHour = rateLimits.five_hour != null;
+ const hasSevenDay = rateLimits.seven_day != null;
+
+ const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0;
+ const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0;
+
+ // Token display: "50k/1M"
+ const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`;
+
+ return (
+
+ {/* Model name */}
+ {shortModel}
+
+ {/* Context usage with token counts */}
+
+ Context
+ {contextUsedPct}%
+ {!narrow && ({tokenDisplay})}
+
+ {/* 5-hour session rate limit */}
+ {hasFiveHour && (
+ <>
+
+ Session
+ {wide && (
+ <>
+
+
+ >
+ )}
+ {fiveHourPct}%
+ {!narrow && rateLimits.five_hour!.resets_at > 0 && (
+ {formatCountdown(rateLimits.five_hour!.resets_at)}
+ )}
+ >
+ )}
+
+ {/* 7-day weekly rate limit */}
+ {hasSevenDay && (
+ <>
+
+ Weekly
+ {wide && (
+ <>
+
+
+ >
+ )}
+ {sevenDayPct}%
+ {!narrow && rateLimits.seven_day!.resets_at > 0 && (
+ {formatCountdown(rateLimits.seven_day!.resets_at)}
+ )}
+ >
+ )}
+
+ {/* Cost */}
+ {totalCostUsd > 0 && (
+ <>
+
+ {formatCost(totalCostUsd)}
+ >
+ )}
+
+ );
+}
+
+export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);
diff --git a/src/components/StatusLine.tsx b/src/components/StatusLine.tsx
index 509ccda8..1de2d0fd 100644
--- a/src/components/StatusLine.tsx
+++ b/src/components/StatusLine.tsx
@@ -1,323 +1,61 @@
import { feature } from 'bun:bundle';
import * as React from 'react';
-import { memo, useCallback, useEffect, useRef } from 'react';
-import { logEvent } from 'src/services/analytics/index.js';
-import { useAppState, useSetAppState } from 'src/state/AppState.js';
-import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js';
-import { getIsRemoteMode, getKairosActive, getMainThreadAgentType, getOriginalCwd, getSdkBetas, getSessionId } from '../bootstrap/state.js';
-import { DEFAULT_OUTPUT_STYLE_NAME } from '../constants/outputStyles.js';
-import { useNotifications } from '../context/notifications.js';
-import { getTotalAPIDuration, getTotalCost, getTotalDuration, getTotalInputTokens, getTotalLinesAdded, getTotalLinesRemoved, getTotalOutputTokens } from '../cost-tracker.js';
+import { memo } from 'react';
+import { useAppState } from 'src/state/AppState.js';
+import { getSdkBetas, getKairosActive } from '../bootstrap/state.js';
+import { getTotalCost, getTotalInputTokens, getTotalOutputTokens } from '../cost-tracker.js';
import { useMainLoopModel } from '../hooks/useMainLoopModel.js';
-import { type ReadonlySettings, useSettings } from '../hooks/useSettings.js';
-import { Ansi, Box, Text } from '../ink.js';
+import { type ReadonlySettings } from '../hooks/useSettings.js';
import { getRawUtilization } from '../services/claudeAiLimits.js';
import type { Message } from '../types/message.js';
-import type { StatusLineCommandInput } from '../types/statusLine.js';
-import type { VimMode } from '../types/textInputTypes.js';
-import { checkHasTrustDialogAccepted } from '../utils/config.js';
import { calculateContextPercentages, getContextWindowForModel } from '../utils/context.js';
-import { getCwd } from '../utils/cwd.js';
-import { logForDebugging } from '../utils/debug.js';
-import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
-import { createBaseHookInput, executeStatusLineCommand } from '../utils/hooks.js';
import { getLastAssistantMessage } from '../utils/messages.js';
-import { getRuntimeMainLoopModel, type ModelName, renderModelName } from '../utils/model/model.js';
-import { getCurrentSessionTitle } from '../utils/sessionStorage.js';
+import { getRuntimeMainLoopModel, renderModelName } from '../utils/model/model.js';
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
-import { getCurrentWorktreeSession } from '../utils/worktree.js';
-import { isVimModeEnabled } from './PromptInput/utils.js';
+import { BuiltinStatusLine } from './BuiltinStatusLine.js';
+
export function statusLineShouldDisplay(settings: ReadonlySettings): boolean {
- // Assistant mode: statusline fields (model, permission mode, cwd) reflect the
- // REPL/daemon process, not what the agent child is actually running. Hide it.
if (feature('KAIROS') && getKairosActive()) return false;
- return settings?.statusLine !== undefined;
-}
-function buildStatusLineCommandInput(permissionMode: PermissionMode, exceeds200kTokens: boolean, settings: ReadonlySettings, messages: Message[], addedDirs: string[], mainLoopModel: ModelName, vimMode?: VimMode): StatusLineCommandInput {
- const agentType = getMainThreadAgentType();
- const worktreeSession = getCurrentWorktreeSession();
- const runtimeModel = getRuntimeMainLoopModel({
- permissionMode,
- mainLoopModel,
- exceeds200kTokens
- });
- const outputStyleName = settings?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME;
- const currentUsage = getCurrentUsage(messages);
- const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas());
- const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize);
- const sessionId = getSessionId();
- const sessionName = getCurrentSessionTitle(sessionId);
- const rawUtil = getRawUtilization();
- const rateLimits: StatusLineCommandInput['rate_limits'] = {
- ...(rawUtil.five_hour && {
- five_hour: {
- used_percentage: rawUtil.five_hour.utilization * 100,
- resets_at: rawUtil.five_hour.resets_at
- }
- }),
- ...(rawUtil.seven_day && {
- seven_day: {
- used_percentage: rawUtil.seven_day.utilization * 100,
- resets_at: rawUtil.seven_day.resets_at
- }
- })
- };
- return {
- ...createBaseHookInput(),
- ...(sessionName && {
- session_name: sessionName
- }),
- model: {
- id: runtimeModel,
- display_name: renderModelName(runtimeModel)
- },
- workspace: {
- current_dir: getCwd(),
- project_dir: getOriginalCwd(),
- added_dirs: addedDirs
- },
- version: MACRO.VERSION,
- output_style: {
- name: outputStyleName
- },
- cost: {
- total_cost_usd: getTotalCost(),
- total_duration_ms: getTotalDuration(),
- total_api_duration_ms: getTotalAPIDuration(),
- total_lines_added: getTotalLinesAdded(),
- total_lines_removed: getTotalLinesRemoved()
- },
- context_window: {
- total_input_tokens: getTotalInputTokens(),
- total_output_tokens: getTotalOutputTokens(),
- context_window_size: contextWindowSize,
- current_usage: currentUsage,
- used_percentage: contextPercentages.used,
- remaining_percentage: contextPercentages.remaining
- },
- exceeds_200k_tokens: exceeds200kTokens,
- ...((rateLimits.five_hour || rateLimits.seven_day) && {
- rate_limits: rateLimits
- }),
- ...(isVimModeEnabled() && {
- vim: {
- mode: vimMode ?? 'INSERT'
- }
- }),
- ...(agentType && {
- agent: {
- name: agentType
- }
- }),
- ...(getIsRemoteMode() && {
- remote: {
- session_id: getSessionId()
- }
- }),
- ...(worktreeSession && {
- worktree: {
- name: worktreeSession.worktreeName,
- path: worktreeSession.worktreePath,
- branch: worktreeSession.worktreeBranch,
- original_cwd: worktreeSession.originalCwd,
- original_branch: worktreeSession.originalBranch
- }
- })
- };
+ return true;
}
+
type Props = {
- // messages stays behind a ref (read only in the debounced callback);
- // lastAssistantMessageId is the actual re-render trigger.
messagesRef: React.RefObject;
lastAssistantMessageId: string | null;
- vimMode?: VimMode;
+ vimMode?: unknown;
};
+
export function getLastAssistantMessageId(messages: Message[]): string | null {
return getLastAssistantMessage(messages)?.uuid ?? null;
}
-function StatusLineInner({
- messagesRef,
- lastAssistantMessageId,
- vimMode
-}: Props): React.ReactNode {
- const abortControllerRef = useRef(undefined);
- const permissionMode = useAppState(s => s.toolPermissionContext.mode);
- const additionalWorkingDirectories = useAppState(s => s.toolPermissionContext.additionalWorkingDirectories);
- const statusLineText = useAppState(s => s.statusLineText);
- const setAppState = useSetAppState();
- const settings = useSettings();
- const {
- addNotification
- } = useNotifications();
- // AppState-sourced model — same source as API requests. getMainLoopModel()
- // re-reads settings.json on every call, so another session's /model write
- // would leak into this session's statusline (anthropics/claude-code#37596).
- const mainLoopModel = useMainLoopModel();
-
- // Keep latest values in refs for stable callback access
- const settingsRef = useRef(settings);
- settingsRef.current = settings;
- const vimModeRef = useRef(vimMode);
- vimModeRef.current = vimMode;
- const permissionModeRef = useRef(permissionMode);
- permissionModeRef.current = permissionMode;
- const addedDirsRef = useRef(additionalWorkingDirectories);
- addedDirsRef.current = additionalWorkingDirectories;
- const mainLoopModelRef = useRef(mainLoopModel);
- mainLoopModelRef.current = mainLoopModel;
-
- // Track previous state to detect changes and cache expensive calculations
- const previousStateRef = useRef<{
- messageId: string | null;
- exceeds200kTokens: boolean;
- permissionMode: PermissionMode;
- vimMode: VimMode | undefined;
- mainLoopModel: ModelName;
- }>({
- messageId: null,
- exceeds200kTokens: false,
- permissionMode,
- vimMode,
- mainLoopModel
- });
-
- // Debounce timer ref
- const debounceTimerRef = useRef | undefined>(undefined);
- // True when the next invocation should log its result (first run or after settings reload)
- const logNextResultRef = useRef(true);
-
- // Stable update function — reads latest values from refs
- const doUpdate = useCallback(async () => {
- // Cancel any in-flight requests
- abortControllerRef.current?.abort();
- const controller = new AbortController();
- abortControllerRef.current = controller;
- const msgs = messagesRef.current;
- const logResult = logNextResultRef.current;
- logNextResultRef.current = false;
- try {
- let exceeds200kTokens = previousStateRef.current.exceeds200kTokens;
-
- // Only recalculate 200k check if messages changed
- const currentMessageId = getLastAssistantMessageId(msgs);
- if (currentMessageId !== previousStateRef.current.messageId) {
- exceeds200kTokens = doesMostRecentAssistantMessageExceed200k(msgs);
- previousStateRef.current.messageId = currentMessageId;
- previousStateRef.current.exceeds200kTokens = exceeds200kTokens;
- }
- const statusInput = buildStatusLineCommandInput(permissionModeRef.current, exceeds200kTokens, settingsRef.current, msgs, Array.from(addedDirsRef.current.keys()), mainLoopModelRef.current, vimModeRef.current);
- const text = await executeStatusLineCommand(statusInput, controller.signal, undefined, logResult);
- if (!controller.signal.aborted) {
- setAppState(prev => {
- if (prev.statusLineText === text) return prev;
- return {
- ...prev,
- statusLineText: text
- };
- });
- }
- } catch {
- // Silently ignore errors in status line updates
- }
- }, [messagesRef, setAppState]);
-
- // Stable debounced schedule function — no deps, uses refs
- const scheduleUpdate = useCallback(() => {
- if (debounceTimerRef.current !== undefined) {
- clearTimeout(debounceTimerRef.current);
- }
- debounceTimerRef.current = setTimeout((ref, doUpdate) => {
- ref.current = undefined;
- void doUpdate();
- }, 300, debounceTimerRef, doUpdate);
- }, [doUpdate]);
-
- // Only trigger update when assistant message, permission mode, vim mode, or model actually changes
- useEffect(() => {
- if (lastAssistantMessageId !== previousStateRef.current.messageId || permissionMode !== previousStateRef.current.permissionMode || vimMode !== previousStateRef.current.vimMode || mainLoopModel !== previousStateRef.current.mainLoopModel) {
- // Don't update messageId here — let doUpdate handle it so
- // exceeds200kTokens is recalculated with the latest messages
- previousStateRef.current.permissionMode = permissionMode;
- previousStateRef.current.vimMode = vimMode;
- previousStateRef.current.mainLoopModel = mainLoopModel;
- scheduleUpdate();
- }
- }, [lastAssistantMessageId, permissionMode, vimMode, mainLoopModel, scheduleUpdate]);
-
- // When the statusLine command changes (hot reload), log the next result
- const statusLineCommand = settings?.statusLine?.command;
- const isFirstSettingsRender = useRef(true);
- useEffect(() => {
- if (isFirstSettingsRender.current) {
- isFirstSettingsRender.current = false;
- return;
- }
- logNextResultRef.current = true;
- void doUpdate();
- }, [statusLineCommand, doUpdate]);
-
- // Separate effect for logging on mount
- useEffect(() => {
- const statusLine = settings?.statusLine;
- if (statusLine) {
- logEvent('tengu_status_line_mount', {
- command_length: statusLine.command.length,
- padding: statusLine.padding
- });
- // Log if status line is configured but disabled by disableAllHooks
- if (settings.disableAllHooks === true) {
- logForDebugging('Status line is configured but disableAllHooks is true', {
- level: 'warn'
- });
- }
- // executeStatusLineCommand (hooks.ts) returns undefined when trust is
- // blocked — statusLineText stays undefined forever, user sees nothing,
- // and tengu_status_line_mount above fires anyway so telemetry looks fine.
- if (!checkHasTrustDialogAccepted()) {
- addNotification({
- key: 'statusline-trust-blocked',
- text: 'statusline skipped · restart to fix',
- color: 'warning',
- priority: 'low'
- });
- logForDebugging('Status line command skipped: workspace trust not accepted', {
- level: 'warn'
- });
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional
- }, []); // Only run once on mount - settings stable for initial logging
-
- // Initial update on mount + cleanup on unmount
- useEffect(() => {
- void doUpdate();
- return () => {
- abortControllerRef.current?.abort();
- if (debounceTimerRef.current !== undefined) {
- clearTimeout(debounceTimerRef.current);
- }
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional
- }, []); // Only run once on mount, not when doUpdate changes
+function StatusLineInner({ messagesRef, lastAssistantMessageId }: Props): React.ReactNode {
+ const mainLoopModel = useMainLoopModel();
+ const permissionMode = useAppState(s => s.toolPermissionContext.mode);
- // Get padding from settings or default to 0
- const paddingX = settings?.statusLine?.padding ?? 0;
+ const exceeds200kTokens = lastAssistantMessageId
+ ? doesMostRecentAssistantMessageExceed200k(messagesRef.current)
+ : false;
- // StatusLine must have stable height in fullscreen — the footer is
- // flexShrink:0 so a 0→1 row change when the command finishes steals
- // a row from ScrollBox and shifts content. Reserve the row while loading
- // (same trick as PromptInputFooterLeftSide).
- return
- {statusLineText ?
- {statusLineText}
- : isFullscreenEnvEnabled() ? : null}
- ;
+ const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel, exceeds200kTokens });
+ const modelDisplay = renderModelName(runtimeModel);
+ const currentUsage = getCurrentUsage(messagesRef.current);
+ const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas());
+ const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize);
+ const rawUtil = getRawUtilization();
+ const totalCost = getTotalCost();
+ const usedTokens = getTotalInputTokens() + getTotalOutputTokens();
+
+ return (
+
+ );
}
-// Parent (PromptInputFooter) re-renders on every setMessages, but StatusLine's
-// own props now only change when lastAssistantMessageId flips — memo keeps it
-// from being dragged along (previously ~18 no-prop-change renders per session).
export const StatusLine = memo(StatusLineInner);