diff --git a/src/commands/compact/compact.ts b/src/commands/compact/compact.ts index b12f62a2af..0b5d493e88 100644 --- a/src/commands/compact/compact.ts +++ b/src/commands/compact/compact.ts @@ -23,6 +23,7 @@ import type { ToolUseContext } from '../../Tool.js' import type { LocalCommandCall } from '../../types/command.js' import type { Message } from '../../types/message.js' import { hasExactErrorMessage } from '../../utils/errors.js' +import { formatTokens } from '../../utils/format.js' import { executePreCompactHooks } from '../../utils/hooks.js' import { logError } from '../../utils/log.js' import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' @@ -78,7 +79,10 @@ export const call: LocalCommandCall = async (args, context) => { return { type: 'compact', compactionResult: sessionMemoryResult, - displayText: buildDisplayText(context), + displayText: buildDisplayText(context, undefined, { + pre: sessionMemoryResult.preCompactTokenCount, + post: sessionMemoryResult.truePostCompactTokenCount, + }), } } } @@ -121,7 +125,10 @@ export const call: LocalCommandCall = async (args, context) => { return { type: 'compact', compactionResult: result, - displayText: buildDisplayText(context, result.userDisplayMessage), + displayText: buildDisplayText(context, result.userDisplayMessage, { + pre: result.preCompactTokenCount, + post: result.truePostCompactTokenCount, + }), } } catch (error) { if (abortController.signal.aborted) { @@ -218,7 +225,10 @@ async function compactViaReactive( ...outcome.result!, userDisplayMessage: combinedMessage, }, - displayText: buildDisplayText(context, combinedMessage), + displayText: buildDisplayText(context, combinedMessage, { + pre: outcome.result!.preCompactTokenCount, + post: outcome.result!.truePostCompactTokenCount, + }), } } finally { context.setStreamMode?.('requesting') @@ -231,6 +241,7 @@ async function compactViaReactive( function buildDisplayText( context: ToolUseContext, userDisplayMessage?: string, + tokenCounts?: { pre?: number; post?: number }, ): string { const upgradeMessage = getUpgradeMessage('tip') const expandShortcut = getShortcutDisplay( @@ -238,10 +249,15 @@ function buildDisplayText( 'Global', 'ctrl+o', ) + const tokenLine = + tokenCounts?.pre !== undefined && tokenCounts.post !== undefined + ? `Context: ~${formatTokens(tokenCounts.pre)} \u2192 ~${formatTokens(tokenCounts.post)} tokens` + : undefined const dimmed = [ ...(context.options.verbose ? [] : [`(${expandShortcut} to see full summary)`]), + ...(tokenLine ? [tokenLine] : []), ...(userDisplayMessage ? [userDisplayMessage] : []), ...(upgradeMessage ? [upgradeMessage] : []), ] diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index c7d4eb671d..6c5af9fab3 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -32,7 +32,6 @@ import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js'; import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js'; import { getGlobalConfig } from '../utils/config.js'; import { isEnvTruthy } from '../utils/envUtils.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; import { applyGrouping } from '../utils/groupToolUses.js'; import { buildMessageLookups, @@ -41,7 +40,6 @@ import { updateMessageLookupsIncremental, createAssistantMessage, deriveUUID, - getMessagesAfterCompactBoundary, getToolUseID, getToolUseIDs, hasUnresolvedHooksFromLookup, @@ -543,12 +541,13 @@ const MessagesImpl = ({ // (this PR's core goal — full history in UI, filter only for the model). // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so // projectSnippedView's check against original removedUuids would fail. - const compactAwareMessages = - verbose || isFullscreenEnvEnabled() - ? normalizedMessages - : getMessagesAfterCompactBoundary(normalizedMessages, { - includeSnipped: true, - }); + // Keep the full pre-compact history visible in every view (not just + // verbose/transcript). The model context is filtered separately in + // query.ts via getMessagesAfterCompactBoundary, so retaining the messages + // here only affects display: after /compact the user still sees the prior + // conversation (with the compact_boundary marker inline as the signal), + // while tokens are still freed from the API request. + const compactAwareMessages = normalizedMessages; const messagesToShowNotTruncated = reorderMessagesInUI( compactAwareMessages.filter( diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 33a82a460f..419eb00871 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -63,6 +63,8 @@ type Props = { overrideMessage?: string | null; spinnerSuffix?: string | null; verbose: boolean; + /** True while a compaction summary is streaming — shows a token progress bar. */ + compactProgressActiveRef?: React.RefObject; hasActiveTools?: boolean; /** Leader's turn has completed (no active query). Used to suppress stall-red spinner when only teammates are running. */ leaderIsIdle?: boolean; @@ -110,6 +112,7 @@ function SpinnerWithVerbInner({ overrideMessage, spinnerSuffix, verbose, + compactProgressActiveRef, hasActiveTools = false, leaderIsIdle = false, }: Props): React.ReactNode { @@ -352,6 +355,7 @@ function SpinnerWithVerbInner({ spinnerSuffix={spinnerSuffix} verbose={verbose} columns={columns} + compactProgressActiveRef={compactProgressActiveRef} hasRunningTeammates={hasRunningTeammates} teammateTokens={teammateTokens} foregroundedTeammate={foregroundedTeammate} diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index c2ad3dd764..9f35d61936 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -1,7 +1,7 @@ import figures from 'figures'; import * as React from 'react'; import { useMemo, useRef } from 'react'; -import { Box, Text, useAnimationFrame, stringWidth, Byline } from '@anthropic/ink'; +import { Box, Text, useAnimationFrame, stringWidth, Byline, ProgressBar } from '@anthropic/ink'; import { toInkColor } from '../../utils/ink.js'; import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; import { formatDuration, formatNumber } from '../../utils/format.js'; @@ -48,6 +48,8 @@ export type SpinnerAnimationRowProps = { spinnerSuffix?: string | null; verbose: boolean; columns: number; + /** True while a compaction summary is streaming — renders a token progress bar. */ + compactProgressActiveRef?: React.RefObject; // Teammate-derived (computed by parent from tasks) hasRunningTeammates: boolean; @@ -86,6 +88,7 @@ export function SpinnerAnimationRow({ spinnerSuffix, verbose, columns, + compactProgressActiveRef, hasRunningTeammates, teammateTokens, foregroundedTeammate, @@ -283,7 +286,14 @@ export function SpinnerAnimationRow({ ) ) : null; - return ( + // Compaction progress bar. Summary length is unknown up front, so map + // streamed tokens through an asymptotic curve (approaches but never reaches + // 100%) so it always reads as forward progress. Bar disappears on compact_end. + const isCompacting = compactProgressActiveRef?.current === true; + const compactRatio = isCompacting ? 1 - Math.exp(-leaderTokens / 1200) : 0; + const compactBarWidth = Math.max(10, Math.min(30, columns - 20)); + + const spinnerRow = ( ); + + if (!isCompacting) return spinnerRow; + + return ( + + {spinnerRow} + + + {Math.round(compactRatio * 100)}% + + + ); } function SpinnerModeGlyph({ mode }: { mode: SpinnerMode }): React.ReactNode { diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 54dce8b6d4..05051799b9 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1750,6 +1750,7 @@ export function REPL({ // Ref instead of state to avoid triggering React re-renders on every // streaming text_delta. The spinner reads this via its animation timer. const responseLengthRef = useRef(0); + const compactProgressActiveRef = useRef(false); // API performance metrics ref for ant-only spinner display (TTFT/OTPS). // Accumulates metrics from all API requests in a turn for P50 aggregation. const apiMetricsRef = useRef< @@ -3011,11 +3012,13 @@ export function REPL({ break; case 'compact_start': setSpinnerMessage('Compacting conversation'); + compactProgressActiveRef.current = true; break; case 'compact_end': setSpinnerMessage(null); setSpinnerColor(null); setSpinnerShimmerColor(null); + compactProgressActiveRef.current = false; break; } }, @@ -5953,6 +5956,7 @@ export function REPL({ spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} + compactProgressActiveRef={compactProgressActiveRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} diff --git a/src/services/compact/compact.ts b/src/services/compact/compact.ts index 1ef6c62c92..952019219c 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -1230,7 +1230,13 @@ async function streamCompactSummary({ // Pass the compact context's abortController so user Esc aborts the // fork — same signal the streaming fallback uses at // `signal: context.abortController.signal` below. - overrides: { abortController: context.abortController }, + // Share setResponseLength so streamed summary tokens drive the + // compaction progress bar (the fork path is the default; without + // this the bar sits at 0% until compact_end). + overrides: { + abortController: context.abortController, + shareSetResponseLength: true, + }, }) const assistantMsg = getLastAssistantMessage(result.messages) const assistantText = assistantMsg diff --git a/src/utils/forkedAgent.ts b/src/utils/forkedAgent.ts index 170bdee0dd..579b8d6ff5 100644 --- a/src/utils/forkedAgent.ts +++ b/src/utils/forkedAgent.ts @@ -587,6 +587,18 @@ export async function runForkedAgent({ if (isMessageDeltaStreamEvent(message)) { const turnUsage = updateUsage({ ...EMPTY_USAGE }, message.event.usage) totalUsage = accumulateUsage(totalUsage, turnUsage) + } else { + // Feed streamed text length into the (optionally shared) response + // length callback so callers with shareSetResponseLength can drive + // token-based UI (e.g. the compaction progress bar). No-op by default. + const event = (message as StreamEventMessage).event + if ( + event.type === 'content_block_delta' && + event.delta.type === 'text_delta' + ) { + const textLength = event.delta.text.length + isolatedToolUseContext.setResponseLength(len => len + textLength) + } } continue }