Skip to content

Commit 0b77c39

Browse files
unraidclaude
andcommitted
refactor: remove external command status line, keep built-in only
Simplify StatusLine.tsx by removing the external shell command path (ExternalStatusLine, buildStatusLineCommandInput, executeStatusLineCommand). The built-in status line is now the only implementation — no configuration needed, shows model, context, session/weekly quotas, and cost by default. Also adds status line screenshot for documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f749332 commit 0b77c39

2 files changed

Lines changed: 21 additions & 338 deletions

File tree

QQ20260402-192932.png

144 KB
Loading

src/components/StatusLine.tsx

Lines changed: 21 additions & 338 deletions
Original file line numberDiff line numberDiff line change
@@ -1,172 +1,38 @@
11
import { feature } from 'bun:bundle';
22
import * 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';
117
import { 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';
149
import { getRawUtilization } from '../services/claudeAiLimits.js';
1510
import 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';
1911
import { 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';
2412
import { 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';
2714
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
28-
import { getCurrentWorktreeSession } from '../utils/worktree.js';
2915
import { BuiltinStatusLine } from './BuiltinStatusLine.js';
30-
import { isVimModeEnabled } from './PromptInput/utils.js';
16+
3117
export 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+
12922
type 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+
13628
export 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).
37861
export const StatusLine = memo(StatusLineInner);

0 commit comments

Comments
 (0)