diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index ec67c0fe9a..f4294aa89a 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -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 @@ -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) { @@ -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 diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index df4fb98929..2af518b1a4 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -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, []); @@ -4762,7 +4775,7 @@ export function REPL({ } {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} - {showSpinner && 0} leaderIsIdle={!isLoading} />} + {showSpinner && 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. */} diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index c381492ede..ad34351951 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -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' @@ -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