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 { useMainLoopModel } from '../hooks/useMainLoopModel.js' import { type ReadonlySettings, useSettings } from '../hooks/useSettings.js' import { Ansi, Box, Text } from '@anthropic/ink' 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 { doesMostRecentAssistantMessageExceed200k, getCurrentUsage, } from '../utils/tokens.js' import { getCurrentWorktreeSession } from '../utils/worktree.js' import { isVimModeEnabled } from './PromptInput/utils.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, }, }), } } 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 } 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 }, []) // 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 }, []) // Only run once on mount, not when doUpdate changes // Get padding from settings or default to 0 const paddingX = settings?.statusLine?.padding ?? 0 // 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} ) } // 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)