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