Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/components/Spinner/SpinnerAnimationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { useStalledAnimation } from './useStalledAnimation.js';
import { interpolateColor, toRGBColor } from './utils.js';
const SEP_WIDTH = stringWidth(' · ');
const THINKING_BARE_WIDTH = stringWidth('thinking');
// Show the elapsed-time counter early (reassurance that work is in flight,
// especially during long tool calls where no tokens stream) but hold the token
// count back until the turn is clearly long-running.
const SHOW_TIMER_AFTER_MS = 5_000;
const SHOW_TOKENS_AFTER_MS = 30_000;

// Thinking shimmer constants. Previously lived in a separate ThinkingShimmerText
Expand Down Expand Up @@ -183,7 +187,8 @@ export function SpinnerAnimationRow({
// the status parts, so reserve that space in the gating math too.
const parensWidth = hasRunningTeammates ? 4 : 4 + 2 + SEP_WIDTH;
const wantsThinking = thinkingStatus !== null;
const wantsTimerAndTokens = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS;
const wantsTimer = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TIMER_AFTER_MS;
const wantsTokens = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS;
const availableSpace = columns - messageWidth - parensWidth;
let showThinking = wantsThinking && availableSpace > thinkingWidthValue;
if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && effortSuffix) {
Expand All @@ -194,9 +199,9 @@ export function SpinnerAnimationRow({
}
}
const usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0;
const showTimer = wantsTimerAndTokens && availableSpace > usedAfterThinking + timerWidth;
const showTimer = wantsTimer && availableSpace > usedAfterThinking + timerWidth;
const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0);
const showTokens = wantsTimerAndTokens && totalTokens > 0 && availableSpace > usedAfterTimer + tokensWidth;
const showTokens = wantsTokens && totalTokens > 0 && availableSpace > usedAfterTimer + tokensWidth;
// Second chance for narrow terminals: the gating above reserves space for
// the mode glyph + separator, but a would-be thinking-only spin renders
// neither the glyph nor the wrapping parens beyond "( )". When nothing
Expand Down
15 changes: 14 additions & 1 deletion src/screens/REPL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,19 @@ export function REPL({
const inProgressToolUses = lastAssistant.message.content.filter(b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id));
return inProgressToolUses.length > 0 && inProgressToolUses.every(b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME);
}, [messages, inProgressToolUseIDs]);
// Surface the currently-executing tool in the spinner so long-running tools
// (subagents, typecheck, installs) don't look frozen during the elapsed
// crunch. Reuses the spinnerSuffix channel; stop-hook progress takes
// precedence when both apply (stop hooks run after the turn's tools).
const activeToolSpinnerSuffix = useMemo(() => {
if (!isLoading || inProgressToolUseIDs.size === 0) return null;
const lastAssistant = messages.findLast(m => m.type === 'assistant');
if (lastAssistant?.type !== 'assistant') return null;
const active = lastAssistant.message.content.filter(b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id));
const first = active[0];
if (!first || first.type !== 'tool_use') return null;
return active.length > 1 ? `${first.name} +${active.length - 1}` : first.name;
}, [messages, inProgressToolUseIDs, isLoading]);
const mrOnBeforeQuery = useCallback(async (_input: string, _allMessages: MessageType[], _newMessageCount: number) => true, []);
const mrOnTurnComplete = useCallback(async (_allMessages: MessageType[], _aborted: boolean) => { }, []);
const mrRender = useCallback(() => null, []);
Expand Down Expand Up @@ -4762,7 +4775,7 @@ export function REPL({
</Box>}
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
<Box flexGrow={1} />
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix ?? activeToolSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
{/* Permanently mounted: it observes the isLoading transition to flash
`✓ Done` for ~1.5s. Suppressed wherever another element owns the
row or the user's attention. */}
Expand Down
14 changes: 10 additions & 4 deletions src/services/api/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import {
shouldUseIntegrationRuntimeLimits,
} from '../../utils/context.js'
import { resolveAppliedEffort } from '../../utils/effort.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js'
import { errorMessage } from '../../utils/errors.js'
import { computeFingerprintFromMessages } from '../../utils/fingerprint.js'
import { captureAPIRequest, logError } from '../../utils/log.js'
Expand Down Expand Up @@ -1950,9 +1950,15 @@ async function* queryModel(
// kill hung streams. Without this, a silently dropped connection can hang
// the session indefinitely since the SDK's request timeout only covers the
// initial fetch(), not the streaming body.
const streamWatchdogEnabled = isEnvTruthy(
process.env.CLAUDE_ENABLE_STREAM_WATCHDOG,
)
// Enabled by default, matching the always-on idle timeout already used by
// the OpenAI/Codex shims (readWithTimeout). A silently dropped Anthropic
// stream now aborts and falls back to a non-streaming retry within
// STREAM_IDLE_TIMEOUT_MS, instead of hanging until QueryGuard's 5-minute
// idle timeout. Opt out with CLAUDE_DISABLE_STREAM_WATCHDOG=1 (or by
// explicitly setting CLAUDE_ENABLE_STREAM_WATCHDOG to a falsy value).
const streamWatchdogEnabled =
!isEnvTruthy(process.env.CLAUDE_DISABLE_STREAM_WATCHDOG) &&
!isEnvDefinedFalsy(process.env.CLAUDE_ENABLE_STREAM_WATCHDOG)
const STREAM_IDLE_TIMEOUT_MS =
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
const STREAM_IDLE_WARNING_MS = STREAM_IDLE_TIMEOUT_MS / 2
Expand Down