11import { feature } from 'bun:bundle' ;
22import * as React from 'react' ;
3- import { memo , useCallback , useEffect , useRef } from 'react' ;
4- import { logEvent } from 'src/services/analytics/index.js' ;
5- import { useAppState , useSetAppState } from 'src/state/AppState.js' ;
6- import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js' ;
7- import { getIsRemoteMode , getKairosActive , getMainThreadAgentType , getOriginalCwd , getSdkBetas , getSessionId } from '../bootstrap/state.js' ;
8- import { DEFAULT_OUTPUT_STYLE_NAME } from '../constants/outputStyles.js' ;
9- import { useNotifications } from '../context/notifications.js' ;
10- import { getTotalAPIDuration , getTotalCost , getTotalDuration , getTotalInputTokens , getTotalLinesAdded , getTotalLinesRemoved , getTotalOutputTokens } from '../cost-tracker.js' ;
3+ import { memo } from 'react' ;
4+ import { useAppState } from 'src/state/AppState.js' ;
5+ import { getSdkBetas , getKairosActive } from '../bootstrap/state.js' ;
6+ import { getTotalCost , getTotalInputTokens , getTotalOutputTokens } from '../cost-tracker.js' ;
117import { useMainLoopModel } from '../hooks/useMainLoopModel.js' ;
12- import { type ReadonlySettings , useSettings } from '../hooks/useSettings.js' ;
13- import { Ansi , Box , Text } from '../ink.js' ;
8+ import { type ReadonlySettings } from '../hooks/useSettings.js' ;
149import { getRawUtilization } from '../services/claudeAiLimits.js' ;
1510import type { Message } from '../types/message.js' ;
16- import type { StatusLineCommandInput } from '../types/statusLine.js' ;
17- import type { VimMode } from '../types/textInputTypes.js' ;
18- import { checkHasTrustDialogAccepted } from '../utils/config.js' ;
1911import { calculateContextPercentages , getContextWindowForModel } from '../utils/context.js' ;
20- import { getCwd } from '../utils/cwd.js' ;
21- import { logForDebugging } from '../utils/debug.js' ;
22- import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' ;
23- import { createBaseHookInput , executeStatusLineCommand } from '../utils/hooks.js' ;
2412import { getLastAssistantMessage } from '../utils/messages.js' ;
25- import { getRuntimeMainLoopModel , type ModelName , renderModelName } from '../utils/model/model.js' ;
26- import { getCurrentSessionTitle } from '../utils/sessionStorage.js' ;
13+ import { getRuntimeMainLoopModel , renderModelName } from '../utils/model/model.js' ;
2714import { doesMostRecentAssistantMessageExceed200k , getCurrentUsage } from '../utils/tokens.js' ;
28- import { getCurrentWorktreeSession } from '../utils/worktree.js' ;
2915import { BuiltinStatusLine } from './BuiltinStatusLine.js' ;
30- import { isVimModeEnabled } from './PromptInput/utils.js' ;
16+
3117export function statusLineShouldDisplay ( settings : ReadonlySettings ) : boolean {
32- // Assistant mode: statusline fields (model, permission mode, cwd) reflect the
33- // REPL/daemon process, not what the agent child is actually running. Hide it.
3418 if ( feature ( 'KAIROS' ) && getKairosActive ( ) ) return false ;
3519 return true ;
3620}
37- function buildStatusLineCommandInput ( permissionMode : PermissionMode , exceeds200kTokens : boolean , settings : ReadonlySettings , messages : Message [ ] , addedDirs : string [ ] , mainLoopModel : ModelName , vimMode ?: VimMode ) : StatusLineCommandInput {
38- const agentType = getMainThreadAgentType ( ) ;
39- const worktreeSession = getCurrentWorktreeSession ( ) ;
40- const runtimeModel = getRuntimeMainLoopModel ( {
41- permissionMode,
42- mainLoopModel,
43- exceeds200kTokens
44- } ) ;
45- const outputStyleName = settings ?. outputStyle || DEFAULT_OUTPUT_STYLE_NAME ;
46- const currentUsage = getCurrentUsage ( messages ) ;
47- const contextWindowSize = getContextWindowForModel ( runtimeModel , getSdkBetas ( ) ) ;
48- const contextPercentages = calculateContextPercentages ( currentUsage , contextWindowSize ) ;
49- const sessionId = getSessionId ( ) ;
50- const sessionName = getCurrentSessionTitle ( sessionId ) ;
51- const rawUtil = getRawUtilization ( ) ;
52- const rateLimits : StatusLineCommandInput [ 'rate_limits' ] = {
53- ...( rawUtil . five_hour && {
54- five_hour : {
55- used_percentage : rawUtil . five_hour . utilization * 100 ,
56- resets_at : rawUtil . five_hour . resets_at
57- }
58- } ) ,
59- ...( rawUtil . seven_day && {
60- seven_day : {
61- used_percentage : rawUtil . seven_day . utilization * 100 ,
62- resets_at : rawUtil . seven_day . resets_at
63- }
64- } )
65- } ;
66- return {
67- ...createBaseHookInput ( ) ,
68- ...( sessionName && {
69- session_name : sessionName
70- } ) ,
71- model : {
72- id : runtimeModel ,
73- display_name : renderModelName ( runtimeModel )
74- } ,
75- workspace : {
76- current_dir : getCwd ( ) ,
77- project_dir : getOriginalCwd ( ) ,
78- added_dirs : addedDirs
79- } ,
80- version : MACRO . VERSION ,
81- output_style : {
82- name : outputStyleName
83- } ,
84- cost : {
85- total_cost_usd : getTotalCost ( ) ,
86- total_duration_ms : getTotalDuration ( ) ,
87- total_api_duration_ms : getTotalAPIDuration ( ) ,
88- total_lines_added : getTotalLinesAdded ( ) ,
89- total_lines_removed : getTotalLinesRemoved ( )
90- } ,
91- context_window : {
92- total_input_tokens : getTotalInputTokens ( ) ,
93- total_output_tokens : getTotalOutputTokens ( ) ,
94- context_window_size : contextWindowSize ,
95- current_usage : currentUsage ,
96- used_percentage : contextPercentages . used ,
97- remaining_percentage : contextPercentages . remaining
98- } ,
99- exceeds_200k_tokens : exceeds200kTokens ,
100- ...( ( rateLimits . five_hour || rateLimits . seven_day ) && {
101- rate_limits : rateLimits
102- } ) ,
103- ...( isVimModeEnabled ( ) && {
104- vim : {
105- mode : vimMode ?? 'INSERT'
106- }
107- } ) ,
108- ...( agentType && {
109- agent : {
110- name : agentType
111- }
112- } ) ,
113- ...( getIsRemoteMode ( ) && {
114- remote : {
115- session_id : getSessionId ( )
116- }
117- } ) ,
118- ...( worktreeSession && {
119- worktree : {
120- name : worktreeSession . worktreeName ,
121- path : worktreeSession . worktreePath ,
122- branch : worktreeSession . worktreeBranch ,
123- original_cwd : worktreeSession . originalCwd ,
124- original_branch : worktreeSession . originalBranch
125- }
126- } )
127- } ;
128- }
21+
12922type Props = {
130- // messages stays behind a ref (read only in the debounced callback);
131- // lastAssistantMessageId is the actual re-render trigger.
13223 messagesRef : React . RefObject < Message [ ] > ;
13324 lastAssistantMessageId : string | null ;
134- vimMode ?: VimMode ;
25+ vimMode ?: unknown ;
13526} ;
27+
13628export function getLastAssistantMessageId ( messages : Message [ ] ) : string | null {
13729 return getLastAssistantMessage ( messages ) ?. uuid ?? null ;
13830}
139- function StatusLineInner ( {
140- messagesRef,
141- lastAssistantMessageId,
142- 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- }
15431
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 {
32+ function StatusLineInner ( { messagesRef, lastAssistantMessageId } : Props ) : React . ReactNode {
16633 const mainLoopModel = useMainLoopModel ( ) ;
16734 const permissionMode = useAppState ( s => s . toolPermissionContext . mode ) ;
16835
169- // Compute exceeds200k only when message changes
17036 const exceeds200kTokens = lastAssistantMessageId
17137 ? doesMostRecentAssistantMessageExceed200k ( messagesRef . current )
17238 : false ;
@@ -180,199 +46,16 @@ function BuiltinStatusLineWrapper({ messagesRef, lastAssistantMessageId }: {
18046 const totalCost = getTotalCost ( ) ;
18147 const usedTokens = getTotalInputTokens ( ) + getTotalOutputTokens ( ) ;
18248
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
197- } : Props ) : React . ReactNode {
198- const abortControllerRef = useRef < AbortController | undefined > ( undefined ) ;
199- const permissionMode = useAppState ( s => s . toolPermissionContext . mode ) ;
200- const additionalWorkingDirectories = useAppState ( s => s . toolPermissionContext . additionalWorkingDirectories ) ;
201- const statusLineText = useAppState ( s => s . statusLineText ) ;
202- const setAppState = useSetAppState ( ) ;
203- const settings = useSettings ( ) ;
204- const {
205- addNotification
206- } = useNotifications ( ) ;
207- // AppState-sourced model — same source as API requests. getMainLoopModel()
208- // re-reads settings.json on every call, so another session's /model write
209- // would leak into this session's statusline (anthropics/claude-code#37596).
210- const mainLoopModel = useMainLoopModel ( ) ;
211-
212- // Keep latest values in refs for stable callback access
213- const settingsRef = useRef ( settings ) ;
214- settingsRef . current = settings ;
215- const vimModeRef = useRef ( vimMode ) ;
216- vimModeRef . current = vimMode ;
217- const permissionModeRef = useRef ( permissionMode ) ;
218- permissionModeRef . current = permissionMode ;
219- const addedDirsRef = useRef ( additionalWorkingDirectories ) ;
220- addedDirsRef . current = additionalWorkingDirectories ;
221- const mainLoopModelRef = useRef ( mainLoopModel ) ;
222- mainLoopModelRef . current = mainLoopModel ;
223-
224- // Track previous state to detect changes and cache expensive calculations
225- const previousStateRef = useRef < {
226- messageId : string | null ;
227- exceeds200kTokens : boolean ;
228- permissionMode : PermissionMode ;
229- vimMode : VimMode | undefined ;
230- mainLoopModel : ModelName ;
231- } > ( {
232- messageId : null ,
233- exceeds200kTokens : false ,
234- permissionMode,
235- vimMode,
236- mainLoopModel
237- } ) ;
238-
239- // Debounce timer ref
240- const debounceTimerRef = useRef < ReturnType < typeof setTimeout > | undefined > ( undefined ) ;
241-
242- // True when the next invocation should log its result (first run or after settings reload)
243- const logNextResultRef = useRef ( true ) ;
244-
245- // Stable update function — reads latest values from refs
246- const doUpdate = useCallback ( async ( ) => {
247- // Cancel any in-flight requests
248- abortControllerRef . current ?. abort ( ) ;
249- const controller = new AbortController ( ) ;
250- abortControllerRef . current = controller ;
251- const msgs = messagesRef . current ;
252- const logResult = logNextResultRef . current ;
253- logNextResultRef . current = false ;
254- try {
255- let exceeds200kTokens = previousStateRef . current . exceeds200kTokens ;
256-
257- // Only recalculate 200k check if messages changed
258- const currentMessageId = getLastAssistantMessageId ( msgs ) ;
259- if ( currentMessageId !== previousStateRef . current . messageId ) {
260- exceeds200kTokens = doesMostRecentAssistantMessageExceed200k ( msgs ) ;
261- previousStateRef . current . messageId = currentMessageId ;
262- previousStateRef . current . exceeds200kTokens = exceeds200kTokens ;
263- }
264- const statusInput = buildStatusLineCommandInput ( permissionModeRef . current , exceeds200kTokens , settingsRef . current , msgs , Array . from ( addedDirsRef . current . keys ( ) ) , mainLoopModelRef . current , vimModeRef . current ) ;
265- const text = await executeStatusLineCommand ( statusInput , controller . signal , undefined , logResult ) ;
266- if ( ! controller . signal . aborted ) {
267- setAppState ( prev => {
268- if ( prev . statusLineText === text ) return prev ;
269- return {
270- ...prev ,
271- statusLineText : text
272- } ;
273- } ) ;
274- }
275- } catch {
276- // Silently ignore errors in status line updates
277- }
278- } , [ messagesRef , setAppState ] ) ;
279-
280- // Stable debounced schedule function — no deps, uses refs
281- const scheduleUpdate = useCallback ( ( ) => {
282- if ( debounceTimerRef . current !== undefined ) {
283- clearTimeout ( debounceTimerRef . current ) ;
284- }
285- debounceTimerRef . current = setTimeout ( ( ref , doUpdate ) => {
286- ref . current = undefined ;
287- void doUpdate ( ) ;
288- } , 300 , debounceTimerRef , doUpdate ) ;
289- } , [ doUpdate ] ) ;
290-
291- // Only trigger update when assistant message, permission mode, vim mode, or model actually changes
292- useEffect ( ( ) => {
293- if ( lastAssistantMessageId !== previousStateRef . current . messageId || permissionMode !== previousStateRef . current . permissionMode || vimMode !== previousStateRef . current . vimMode || mainLoopModel !== previousStateRef . current . mainLoopModel ) {
294- // Don't update messageId here — let doUpdate handle it so
295- // exceeds200kTokens is recalculated with the latest messages
296- previousStateRef . current . permissionMode = permissionMode ;
297- previousStateRef . current . vimMode = vimMode ;
298- previousStateRef . current . mainLoopModel = mainLoopModel ;
299- scheduleUpdate ( ) ;
300- }
301- } , [ lastAssistantMessageId , permissionMode , vimMode , mainLoopModel , scheduleUpdate ] ) ;
302-
303- // When the statusLine command changes (hot reload), log the next result
304- const statusLineCommand = settings ?. statusLine ?. command ;
305- const isFirstSettingsRender = useRef ( true ) ;
306- useEffect ( ( ) => {
307- if ( isFirstSettingsRender . current ) {
308- isFirstSettingsRender . current = false ;
309- return ;
310- }
311- logNextResultRef . current = true ;
312- void doUpdate ( ) ;
313- } , [ statusLineCommand , doUpdate ] ) ;
314-
315- // Separate effect for logging on mount
316- useEffect ( ( ) => {
317- const statusLine = settings ?. statusLine ;
318- if ( statusLine ) {
319- logEvent ( 'tengu_status_line_mount' , {
320- command_length : statusLine . command . length ,
321- padding : statusLine . padding
322- } ) ;
323- // Log if status line is configured but disabled by disableAllHooks
324- if ( settings . disableAllHooks === true ) {
325- logForDebugging ( 'Status line is configured but disableAllHooks is true' , {
326- level : 'warn'
327- } ) ;
328- }
329- // executeStatusLineCommand (hooks.ts) returns undefined when trust is
330- // blocked — statusLineText stays undefined forever, user sees nothing,
331- // and tengu_status_line_mount above fires anyway so telemetry looks fine.
332- if ( ! checkHasTrustDialogAccepted ( ) ) {
333- addNotification ( {
334- key : 'statusline-trust-blocked' ,
335- text : 'statusline skipped · restart to fix' ,
336- color : 'warning' ,
337- priority : 'low'
338- } ) ;
339- logForDebugging ( 'Status line command skipped: workspace trust not accepted' , {
340- level : 'warn'
341- } ) ;
342- }
343- }
344- // eslint-disable-next-line react-hooks/exhaustive-deps
345- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional
346- } , [ ] ) ; // Only run once on mount - settings stable for initial logging
347-
348- // Initial update on mount + cleanup on unmount
349- useEffect ( ( ) => {
350- void doUpdate ( ) ;
351- return ( ) => {
352- abortControllerRef . current ?. abort ( ) ;
353- if ( debounceTimerRef . current !== undefined ) {
354- clearTimeout ( debounceTimerRef . current ) ;
355- }
356- } ;
357- // eslint-disable-next-line react-hooks/exhaustive-deps
358- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional
359- } , [ ] ) ; // Only run once on mount, not when doUpdate changes
360-
361- // Get padding from settings or default to 0
362- const paddingX = settings ?. statusLine ?. padding ?? 0 ;
363-
364- // StatusLine must have stable height in fullscreen — the footer is
365- // flexShrink:0 so a 0→1 row change when the command finishes steals
366- // a row from ScrollBox and shifts content. Reserve the row while loading
367- // (same trick as PromptInputFooterLeftSide).
368- return < Box paddingX = { paddingX } gap = { 2 } >
369- { statusLineText ? < Text dimColor wrap = "truncate" >
370- < Ansi > { statusLineText } </ Ansi >
371- </ Text > : isFullscreenEnvEnabled ( ) ? < Text > </ Text > : null }
372- </ Box > ;
49+ return (
50+ < BuiltinStatusLine
51+ modelName = { modelDisplay }
52+ contextUsedPct = { contextPercentages . used }
53+ usedTokens = { usedTokens }
54+ contextWindowSize = { contextWindowSize }
55+ totalCostUsd = { totalCost }
56+ rateLimits = { rawUtil }
57+ />
58+ ) ;
37359}
37460
375- // Parent (PromptInputFooter) re-renders on every setMessages, but StatusLine's
376- // own props now only change when lastAssistantMessageId flips — memo keeps it
377- // from being dragged along (previously ~18 no-prop-change renders per session).
37861export const StatusLine = memo ( StatusLineInner ) ;
0 commit comments