Skip to content

Commit e09071a

Browse files
universe-hcyclaude
andcommitted
feat: /compact 保留上下文并新增流式 token 进度条
- 默认视图保留 compact 前完整历史(不折叠不置灰),仅为显示层改动; 模型上下文仍在 query.ts 按 compact boundary 独立切片,token 照常释放 - compact 阶段新增基于流式 token 的进度条(渐近曲线 1-e^(-tokens/1200)), compact 结束后自动消失 - compact 结束打印压缩前后token变化量 Co-Authored-By: claude-opus-4-8[1m] <noreply@anthropic.com>
1 parent 75e2b3b commit e09071a

7 files changed

Lines changed: 77 additions & 14 deletions

File tree

src/commands/compact/compact.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { ToolUseContext } from '../../Tool.js'
2323
import type { LocalCommandCall } from '../../types/command.js'
2424
import type { Message } from '../../types/message.js'
2525
import { hasExactErrorMessage } from '../../utils/errors.js'
26+
import { formatTokens } from '../../utils/format.js'
2627
import { executePreCompactHooks } from '../../utils/hooks.js'
2728
import { logError } from '../../utils/log.js'
2829
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
@@ -78,7 +79,10 @@ export const call: LocalCommandCall = async (args, context) => {
7879
return {
7980
type: 'compact',
8081
compactionResult: sessionMemoryResult,
81-
displayText: buildDisplayText(context),
82+
displayText: buildDisplayText(context, undefined, {
83+
pre: sessionMemoryResult.preCompactTokenCount,
84+
post: sessionMemoryResult.truePostCompactTokenCount,
85+
}),
8286
}
8387
}
8488
}
@@ -121,7 +125,10 @@ export const call: LocalCommandCall = async (args, context) => {
121125
return {
122126
type: 'compact',
123127
compactionResult: result,
124-
displayText: buildDisplayText(context, result.userDisplayMessage),
128+
displayText: buildDisplayText(context, result.userDisplayMessage, {
129+
pre: result.preCompactTokenCount,
130+
post: result.truePostCompactTokenCount,
131+
}),
125132
}
126133
} catch (error) {
127134
if (abortController.signal.aborted) {
@@ -218,7 +225,10 @@ async function compactViaReactive(
218225
...outcome.result!,
219226
userDisplayMessage: combinedMessage,
220227
},
221-
displayText: buildDisplayText(context, combinedMessage),
228+
displayText: buildDisplayText(context, combinedMessage, {
229+
pre: outcome.result!.preCompactTokenCount,
230+
post: outcome.result!.truePostCompactTokenCount,
231+
}),
222232
}
223233
} finally {
224234
context.setStreamMode?.('requesting')
@@ -231,17 +241,23 @@ async function compactViaReactive(
231241
function buildDisplayText(
232242
context: ToolUseContext,
233243
userDisplayMessage?: string,
244+
tokenCounts?: { pre?: number; post?: number },
234245
): string {
235246
const upgradeMessage = getUpgradeMessage('tip')
236247
const expandShortcut = getShortcutDisplay(
237248
'app:toggleTranscript',
238249
'Global',
239250
'ctrl+o',
240251
)
252+
const tokenLine =
253+
tokenCounts?.pre !== undefined && tokenCounts.post !== undefined
254+
? `Context: ~${formatTokens(tokenCounts.pre)} \u2192 ~${formatTokens(tokenCounts.post)} tokens`
255+
: undefined
241256
const dimmed = [
242257
...(context.options.verbose
243258
? []
244259
: [`(${expandShortcut} to see full summary)`]),
260+
...(tokenLine ? [tokenLine] : []),
245261
...(userDisplayMessage ? [userDisplayMessage] : []),
246262
...(upgradeMessage ? [upgradeMessage] : []),
247263
]

src/components/Messages.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js';
3232
import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js';
3333
import { getGlobalConfig } from '../utils/config.js';
3434
import { isEnvTruthy } from '../utils/envUtils.js';
35-
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
3635
import { applyGrouping } from '../utils/groupToolUses.js';
3736
import {
3837
buildMessageLookups,
@@ -41,7 +40,6 @@ import {
4140
updateMessageLookupsIncremental,
4241
createAssistantMessage,
4342
deriveUUID,
44-
getMessagesAfterCompactBoundary,
4543
getToolUseID,
4644
getToolUseIDs,
4745
hasUnresolvedHooksFromLookup,
@@ -543,12 +541,13 @@ const MessagesImpl = ({
543541
// (this PR's core goal — full history in UI, filter only for the model).
544542
// Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so
545543
// projectSnippedView's check against original removedUuids would fail.
546-
const compactAwareMessages =
547-
verbose || isFullscreenEnvEnabled()
548-
? normalizedMessages
549-
: getMessagesAfterCompactBoundary(normalizedMessages, {
550-
includeSnipped: true,
551-
});
544+
// Keep the full pre-compact history visible in every view (not just
545+
// verbose/transcript). The model context is filtered separately in
546+
// query.ts via getMessagesAfterCompactBoundary, so retaining the messages
547+
// here only affects display: after /compact the user still sees the prior
548+
// conversation (with the compact_boundary marker inline as the signal),
549+
// while tokens are still freed from the API request.
550+
const compactAwareMessages = normalizedMessages;
552551

553552
const messagesToShowNotTruncated = reorderMessagesInUI(
554553
compactAwareMessages.filter(

src/components/Spinner.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ type Props = {
6363
overrideMessage?: string | null;
6464
spinnerSuffix?: string | null;
6565
verbose: boolean;
66+
/** True while a compaction summary is streaming — shows a token progress bar. */
67+
compactProgressActiveRef?: React.RefObject<boolean>;
6668
hasActiveTools?: boolean;
6769
/** Leader's turn has completed (no active query). Used to suppress stall-red spinner when only teammates are running. */
6870
leaderIsIdle?: boolean;
@@ -110,6 +112,7 @@ function SpinnerWithVerbInner({
110112
overrideMessage,
111113
spinnerSuffix,
112114
verbose,
115+
compactProgressActiveRef,
113116
hasActiveTools = false,
114117
leaderIsIdle = false,
115118
}: Props): React.ReactNode {
@@ -352,6 +355,7 @@ function SpinnerWithVerbInner({
352355
spinnerSuffix={spinnerSuffix}
353356
verbose={verbose}
354357
columns={columns}
358+
compactProgressActiveRef={compactProgressActiveRef}
355359
hasRunningTeammates={hasRunningTeammates}
356360
teammateTokens={teammateTokens}
357361
foregroundedTeammate={foregroundedTeammate}

src/components/Spinner/SpinnerAnimationRow.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import figures from 'figures';
22
import * as React from 'react';
33
import { useMemo, useRef } from 'react';
4-
import { Box, Text, useAnimationFrame, stringWidth, Byline } from '@anthropic/ink';
4+
import { Box, Text, useAnimationFrame, stringWidth, Byline, ProgressBar } from '@anthropic/ink';
55
import { toInkColor } from '../../utils/ink.js';
66
import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js';
77
import { formatDuration, formatNumber } from '../../utils/format.js';
@@ -48,6 +48,8 @@ export type SpinnerAnimationRowProps = {
4848
spinnerSuffix?: string | null;
4949
verbose: boolean;
5050
columns: number;
51+
/** True while a compaction summary is streaming — renders a token progress bar. */
52+
compactProgressActiveRef?: React.RefObject<boolean>;
5153

5254
// Teammate-derived (computed by parent from tasks)
5355
hasRunningTeammates: boolean;
@@ -86,6 +88,7 @@ export function SpinnerAnimationRow({
8688
spinnerSuffix,
8789
verbose,
8890
columns,
91+
compactProgressActiveRef,
8992
hasRunningTeammates,
9093
teammateTokens,
9194
foregroundedTeammate,
@@ -283,7 +286,14 @@ export function SpinnerAnimationRow({
283286
)
284287
) : null;
285288

286-
return (
289+
// Compaction progress bar. Summary length is unknown up front, so map
290+
// streamed tokens through an asymptotic curve (approaches but never reaches
291+
// 100%) so it always reads as forward progress. Bar disappears on compact_end.
292+
const isCompacting = compactProgressActiveRef?.current === true;
293+
const compactRatio = isCompacting ? 1 - Math.exp(-leaderTokens / 1200) : 0;
294+
const compactBarWidth = Math.max(10, Math.min(30, columns - 20));
295+
296+
const spinnerRow = (
287297
<Box ref={viewportRef} flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
288298
<SpinnerGlyph
289299
frame={frame}
@@ -304,6 +314,18 @@ export function SpinnerAnimationRow({
304314
{status}
305315
</Box>
306316
);
317+
318+
if (!isCompacting) return spinnerRow;
319+
320+
return (
321+
<Box flexDirection="column" width="100%">
322+
{spinnerRow}
323+
<Box flexDirection="row">
324+
<ProgressBar ratio={compactRatio} width={compactBarWidth} fillColor={messageColor} />
325+
<Text dimColor> {Math.round(compactRatio * 100)}%</Text>
326+
</Box>
327+
</Box>
328+
);
307329
}
308330

309331
function SpinnerModeGlyph({ mode }: { mode: SpinnerMode }): React.ReactNode {

src/screens/REPL.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,7 @@ export function REPL({
17501750
// Ref instead of state to avoid triggering React re-renders on every
17511751
// streaming text_delta. The spinner reads this via its animation timer.
17521752
const responseLengthRef = useRef(0);
1753+
const compactProgressActiveRef = useRef(false);
17531754
// API performance metrics ref for ant-only spinner display (TTFT/OTPS).
17541755
// Accumulates metrics from all API requests in a turn for P50 aggregation.
17551756
const apiMetricsRef = useRef<
@@ -3011,11 +3012,13 @@ export function REPL({
30113012
break;
30123013
case 'compact_start':
30133014
setSpinnerMessage('Compacting conversation');
3015+
compactProgressActiveRef.current = true;
30143016
break;
30153017
case 'compact_end':
30163018
setSpinnerMessage(null);
30173019
setSpinnerColor(null);
30183020
setSpinnerShimmerColor(null);
3021+
compactProgressActiveRef.current = false;
30193022
break;
30203023
}
30213024
},
@@ -5953,6 +5956,7 @@ export function REPL({
59535956
spinnerTip={spinnerTip}
59545957
responseLengthRef={responseLengthRef}
59555958
apiMetricsRef={apiMetricsRef}
5959+
compactProgressActiveRef={compactProgressActiveRef}
59565960
overrideMessage={spinnerMessage}
59575961
spinnerSuffix={stopHookSpinnerSuffix}
59585962
verbose={verbose}

src/services/compact/compact.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1230,7 +1230,13 @@ async function streamCompactSummary({
12301230
// Pass the compact context's abortController so user Esc aborts the
12311231
// fork — same signal the streaming fallback uses at
12321232
// `signal: context.abortController.signal` below.
1233-
overrides: { abortController: context.abortController },
1233+
// Share setResponseLength so streamed summary tokens drive the
1234+
// compaction progress bar (the fork path is the default; without
1235+
// this the bar sits at 0% until compact_end).
1236+
overrides: {
1237+
abortController: context.abortController,
1238+
shareSetResponseLength: true,
1239+
},
12341240
})
12351241
const assistantMsg = getLastAssistantMessage(result.messages)
12361242
const assistantText = assistantMsg

src/utils/forkedAgent.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,18 @@ export async function runForkedAgent({
587587
if (isMessageDeltaStreamEvent(message)) {
588588
const turnUsage = updateUsage({ ...EMPTY_USAGE }, message.event.usage)
589589
totalUsage = accumulateUsage(totalUsage, turnUsage)
590+
} else {
591+
// Feed streamed text length into the (optionally shared) response
592+
// length callback so callers with shareSetResponseLength can drive
593+
// token-based UI (e.g. the compaction progress bar). No-op by default.
594+
const event = (message as StreamEventMessage).event
595+
if (
596+
event.type === 'content_block_delta' &&
597+
event.delta.type === 'text_delta'
598+
) {
599+
const textLength = event.delta.text.length
600+
isolatedToolUseContext.setResponseLength(len => len + textLength)
601+
}
590602
}
591603
continue
592604
}

0 commit comments

Comments
 (0)