mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
feat: built-in status line with usage quota display (#89)
* feat: built-in status line with usage quota display Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
src/components/BuiltinStatusLine.tsx
Normal file
152
src/components/BuiltinStatusLine.tsx
Normal file
@@ -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 <Text dimColor>{' \u2502 '}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box wrap="truncate">
|
||||||
|
{/* Model name */}
|
||||||
|
<Text>{shortModel}</Text>
|
||||||
|
|
||||||
|
{/* Context usage with token counts */}
|
||||||
|
<Separator />
|
||||||
|
<Text dimColor>Context </Text>
|
||||||
|
<Text>{contextUsedPct}%</Text>
|
||||||
|
{!narrow && <Text dimColor> ({tokenDisplay})</Text>}
|
||||||
|
|
||||||
|
{/* 5-hour session rate limit */}
|
||||||
|
{hasFiveHour && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<Text dimColor>Session </Text>
|
||||||
|
{wide && (
|
||||||
|
<>
|
||||||
|
<ProgressBar
|
||||||
|
ratio={rateLimits.five_hour!.utilization}
|
||||||
|
width={10}
|
||||||
|
fillColor="rate_limit_fill"
|
||||||
|
emptyColor="rate_limit_empty"
|
||||||
|
/>
|
||||||
|
<Text> </Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text>{fiveHourPct}%</Text>
|
||||||
|
{!narrow && rateLimits.five_hour!.resets_at > 0 && (
|
||||||
|
<Text dimColor> {formatCountdown(rateLimits.five_hour!.resets_at)}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 7-day weekly rate limit */}
|
||||||
|
{hasSevenDay && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<Text dimColor>Weekly </Text>
|
||||||
|
{wide && (
|
||||||
|
<>
|
||||||
|
<ProgressBar
|
||||||
|
ratio={rateLimits.seven_day!.utilization}
|
||||||
|
width={10}
|
||||||
|
fillColor="rate_limit_fill"
|
||||||
|
emptyColor="rate_limit_empty"
|
||||||
|
/>
|
||||||
|
<Text> </Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text>{sevenDayPct}%</Text>
|
||||||
|
{!narrow && rateLimits.seven_day!.resets_at > 0 && (
|
||||||
|
<Text dimColor> {formatCountdown(rateLimits.seven_day!.resets_at)}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cost */}
|
||||||
|
{totalCostUsd > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<Text>{formatCost(totalCostUsd)}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);
|
||||||
@@ -1,323 +1,61 @@
|
|||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
import { memo } from 'react';
|
||||||
import { logEvent } from 'src/services/analytics/index.js';
|
import { useAppState } from 'src/state/AppState.js';
|
||||||
import { useAppState, useSetAppState } from 'src/state/AppState.js';
|
import { getSdkBetas, getKairosActive } from '../bootstrap/state.js';
|
||||||
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js';
|
import { getTotalCost, getTotalInputTokens, getTotalOutputTokens } from '../cost-tracker.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 { useMainLoopModel } from '../hooks/useMainLoopModel.js';
|
||||||
import { type ReadonlySettings, useSettings } from '../hooks/useSettings.js';
|
import { type ReadonlySettings } from '../hooks/useSettings.js';
|
||||||
import { Ansi, Box, Text } from '../ink.js';
|
|
||||||
import { getRawUtilization } from '../services/claudeAiLimits.js';
|
import { getRawUtilization } from '../services/claudeAiLimits.js';
|
||||||
import type { Message } from '../types/message.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 { 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 { getLastAssistantMessage } from '../utils/messages.js';
|
||||||
import { getRuntimeMainLoopModel, type ModelName, renderModelName } from '../utils/model/model.js';
|
import { getRuntimeMainLoopModel, renderModelName } from '../utils/model/model.js';
|
||||||
import { getCurrentSessionTitle } from '../utils/sessionStorage.js';
|
|
||||||
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
|
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
|
||||||
import { getCurrentWorktreeSession } from '../utils/worktree.js';
|
import { BuiltinStatusLine } from './BuiltinStatusLine.js';
|
||||||
import { isVimModeEnabled } from './PromptInput/utils.js';
|
|
||||||
export function statusLineShouldDisplay(settings: ReadonlySettings): boolean {
|
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;
|
if (feature('KAIROS') && getKairosActive()) return false;
|
||||||
return settings?.statusLine !== undefined;
|
return true;
|
||||||
}
|
|
||||||
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 = {
|
type Props = {
|
||||||
// messages stays behind a ref (read only in the debounced callback);
|
|
||||||
// lastAssistantMessageId is the actual re-render trigger.
|
|
||||||
messagesRef: React.RefObject<Message[]>;
|
messagesRef: React.RefObject<Message[]>;
|
||||||
lastAssistantMessageId: string | null;
|
lastAssistantMessageId: string | null;
|
||||||
vimMode?: VimMode;
|
vimMode?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getLastAssistantMessageId(messages: Message[]): string | null {
|
export function getLastAssistantMessageId(messages: Message[]): string | null {
|
||||||
return getLastAssistantMessage(messages)?.uuid ?? null;
|
return getLastAssistantMessage(messages)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
function StatusLineInner({
|
|
||||||
messagesRef,
|
function StatusLineInner({ messagesRef, lastAssistantMessageId }: Props): React.ReactNode {
|
||||||
lastAssistantMessageId,
|
|
||||||
vimMode
|
|
||||||
}: Props): React.ReactNode {
|
|
||||||
const abortControllerRef = useRef<AbortController | undefined>(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();
|
const mainLoopModel = useMainLoopModel();
|
||||||
|
const permissionMode = useAppState(s => s.toolPermissionContext.mode);
|
||||||
|
|
||||||
// Keep latest values in refs for stable callback access
|
const messages = messagesRef.current ?? [];
|
||||||
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 exceeds200kTokens = lastAssistantMessageId ? doesMostRecentAssistantMessageExceed200k(messages) : false;
|
||||||
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 runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel, exceeds200kTokens });
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const modelDisplay = renderModelName(runtimeModel);
|
||||||
|
const currentUsage = getCurrentUsage(messages);
|
||||||
|
const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas());
|
||||||
|
const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize);
|
||||||
|
const rawUtil = getRawUtilization();
|
||||||
|
const totalCost = getTotalCost();
|
||||||
|
const usedTokens = getTotalInputTokens() + getTotalOutputTokens();
|
||||||
|
|
||||||
// True when the next invocation should log its result (first run or after settings reload)
|
return (
|
||||||
const logNextResultRef = useRef(true);
|
<BuiltinStatusLine
|
||||||
|
modelName={modelDisplay}
|
||||||
// Stable update function — reads latest values from refs
|
contextUsedPct={contextPercentages.used}
|
||||||
const doUpdate = useCallback(async () => {
|
usedTokens={usedTokens}
|
||||||
// Cancel any in-flight requests
|
contextWindowSize={contextWindowSize}
|
||||||
abortControllerRef.current?.abort();
|
totalCostUsd={totalCost}
|
||||||
const controller = new AbortController();
|
rateLimits={rawUtil}
|
||||||
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
|
|
||||||
|
|
||||||
// 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 <Box paddingX={paddingX} gap={2}>
|
|
||||||
{statusLineText ? <Text dimColor wrap="truncate">
|
|
||||||
<Ansi>{statusLineText}</Ansi>
|
|
||||||
</Text> : isFullscreenEnvEnabled() ? <Text> </Text> : null}
|
|
||||||
</Box>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
export const StatusLine = memo(StatusLineInner);
|
||||||
|
|||||||
Reference in New Issue
Block a user