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);