diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx
index a8dce2858..90e69b8e3 100644
--- a/crates/agent-gateway/web/src/app/GatewayApp.tsx
+++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx
@@ -4405,6 +4405,21 @@ export default function GatewayApp() {
return item?.title ?? "";
}, [selectedHistoryId, sidebarConversationsById]);
const transcriptRows = displayedTranscript.rows;
+ const contextUsageTokens = useMemo(() => {
+ for (let rowIndex = transcriptRows.length - 1; rowIndex >= 0; rowIndex -= 1) {
+ const row = transcriptRows[rowIndex];
+ if (row.kind === "checkpoint") return undefined;
+ if (row.kind !== "assistant") continue;
+
+ for (let roundIndex = row.rounds.length - 1; roundIndex >= 0; roundIndex -= 1) {
+ const totalTokens = row.rounds[roundIndex].meta?.usageTotalTokens;
+ if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) {
+ return totalTokens;
+ }
+ }
+ }
+ return undefined;
+ }, [transcriptRows]);
// 当前会话的待审批工具:遍历渲染中的 transcript,筛出带 __toolApprovalPending 标记
// 且尚无结果的 tool call(与 ToolCallItem 判定同源)。用于输入框上方的集中审批栏,
// 取代埋在各折叠项里的分散卡片。快照 revision 变化时经 useConversationChat 重渲染,
@@ -4981,6 +4996,8 @@ export default function GatewayApp() {
chatRuntimeControls={chatRuntimeControlsForCurrentProvider}
reasoningOptions={chatRuntimeReasoningOptions}
thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn}
+ contextUsageTokens={contextUsageTokens}
+ contextWindow={currentModelContextWindow}
gitClient={gitClient}
gitWriteEnabled={settings.remote.enableWebGit}
gitDisabledMessage={gitDisabledMessage}
diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx
index dbd3f1cd0..0c36e153a 100644
--- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx
+++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx
@@ -101,6 +101,77 @@ function RuntimeControlTooltip(props: { label: string; children: ReactNode }) {
);
}
+function ContextUsageRing(props: {
+ totalTokens?: number;
+ contextWindow?: number;
+ locale: string;
+ totalLabel: string;
+ contextWindowLabel: string;
+}) {
+ const { totalTokens, contextWindow, locale, totalLabel, contextWindowLabel } = props;
+ if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
+ return null;
+ }
+
+ const normalizedTokens =
+ typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0
+ ? Math.floor(totalTokens)
+ : 0;
+ const rawPercentage = (normalizedTokens / contextWindow) * 100;
+ const displayedPercentage = Math.min(999, Math.round(rawPercentage));
+ const ringPercentage = Math.min(100, Math.max(0, rawPercentage));
+ const formattedTokens = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(
+ normalizedTokens,
+ );
+ const formattedWindow = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(
+ contextWindow,
+ );
+ const label = `${displayedPercentage}% · ${totalLabel} ${formattedTokens} · ${contextWindowLabel} ${formattedWindow}`;
+ const progressClass =
+ rawPercentage >= 90
+ ? "stroke-red-500 dark:stroke-red-400"
+ : rawPercentage >= 70
+ ? "stroke-amber-500 dark:stroke-amber-400"
+ : "stroke-emerald-500 dark:stroke-emerald-400";
+
+ return (
+
+
+
+ {displayedPercentage}%
+
+
+ );
+}
+
function useComposerUploadedImagePreview(
file: PendingUploadedFile,
workdir: string,
@@ -230,6 +301,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
chatRuntimeControls: ChatRuntimeControls;
reasoningOptions: ReasoningLevel[];
thinkingAlwaysOn: boolean;
+ contextUsageTokens?: number;
+ contextWindow?: number;
gitClient?: GitClient | null;
gitWriteEnabled?: boolean;
gitDisabledMessage?: string;
@@ -266,6 +339,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
chatRuntimeControls,
reasoningOptions,
thinkingAlwaysOn,
+ contextUsageTokens,
+ contextWindow,
gitClient,
gitWriteEnabled = true,
gitDisabledMessage,
@@ -288,7 +363,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
onRemoveQueuedTurn,
approvalBar,
} = props;
- const { t } = useLocale();
+ const { t, locale } = useLocale();
const [composerIsEmpty, setComposerIsEmpty] = useState(true);
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
const isComposerExpandedRef = useRef(false);
@@ -1035,6 +1110,13 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
+
{title}
-
- {description}
-
+ {description ? (
+
+ {description}
+
+ ) : null}
diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts
index 1d5a9014a..17c54962d 100644
--- a/crates/agent-gui/src/i18n/config.ts
+++ b/crates/agent-gui/src/i18n/config.ts
@@ -198,6 +198,9 @@ export const translations: Record
> = {
"chat.changedFiles.expand": "展开文件列表",
"chat.compactingContext": "正在压缩上下文",
"chat.compactingContextWait": "正在压缩上下文,请稍候...",
+ "chat.manualCompactTitle": "手动压缩上下文?",
+
+ "chat.manualCompactConfirm": "压缩",
"chat.editMessage": "编辑消息",
"chat.cancel": "取消",
"chat.queue.title": "等待队列 {count}",
@@ -2467,6 +2470,9 @@ export const translations: Record> = {
"chat.changedFiles.expand": "Expand file list",
"chat.compactingContext": "Compressing context",
"chat.compactingContextWait": "Compressing context, please wait...",
+ "chat.manualCompactTitle": "Compact context manually?",
+
+ "chat.manualCompactConfirm": "Compact",
"chat.editMessage": "Edit Message",
"chat.cancel": "Cancel",
"chat.queue.title": "Queue {count}",
diff --git a/crates/agent-gui/src/lib/chat/compaction/controller.ts b/crates/agent-gui/src/lib/chat/compaction/controller.ts
index 77db0d8d1..275f963c1 100644
--- a/crates/agent-gui/src/lib/chat/compaction/controller.ts
+++ b/crates/agent-gui/src/lib/chat/compaction/controller.ts
@@ -244,6 +244,7 @@ export class CompactionController {
async compactDuringRun(params: {
trigger: Exclude;
state: ConversationViewState;
+ force?: boolean;
budgetContext?: Context;
tools?: Context["tools"];
includeAbortedMessages?: boolean;
@@ -292,10 +293,14 @@ export class CompactionController {
: binding.buildPreparedContext(workingState, params.tools, buildOptions);
this.ledger.rebase(budgetContext);
this.updateTurnMeta(workingState);
- const decision = this.decide("protection", this.ledger.total(), now);
+ const decision = this.decide(
+ params.force ? "optimization" : "protection",
+ this.ledger.total(),
+ now,
+ );
this.logDecision(decision);
- if (!decision.shouldCompact) {
+ if (!params.force && !decision.shouldCompact) {
if (pruned) {
binding.sinks.applyStateMidRun?.(pruned.state);
return {
@@ -378,6 +383,35 @@ export class CompactionController {
}
// 用户中止后的统一善后:有快照则回滚(恢复状态/输入框/可选持久化)并返回 true。
+ /**
+ * 用户手动触发的上下文压缩(点击输入区上下文用量环 → 确认):临时绑定一轮并复用主压缩流程,
+ * 强制跳过阈值判断。仅限空闲时调用;若有进行中的回合或绑定则直接返回 false。
+ */
+ async compactNow(params: {
+ state: ConversationViewState;
+ providerId: ProviderId;
+ model: string;
+ runtime: ProviderRuntimeConfig;
+ cancellation: TurnCancellation;
+ debugLogger?: StreamDebugLogger;
+ sinks: CompactionSinks;
+ buildPreparedContext: CompactionTurnBinding["buildPreparedContext"];
+ buildResumeContext: CompactionTurnBinding["buildResumeContext"];
+ }): Promise {
+ if (this.binding || this.inFlight) return false;
+ this.bindTurn({ ...params });
+ try {
+ const result = await this.compactDuringRun({
+ trigger: "manual",
+ state: params.state,
+ force: true,
+ });
+ return result.context !== null;
+ } finally {
+ this.unbindTurn();
+ }
+ }
+
async handleTurnAbort(): Promise {
const binding = this.binding;
const snapshot = this.rollbackSnapshot;
diff --git a/crates/agent-gui/src/lib/chat/compaction/types.ts b/crates/agent-gui/src/lib/chat/compaction/types.ts
index 87fed85cc..03fbe75cb 100644
--- a/crates/agent-gui/src/lib/chat/compaction/types.ts
+++ b/crates/agent-gui/src/lib/chat/compaction/types.ts
@@ -1,6 +1,6 @@
export type { ProviderRuntimeConfig } from "../../providers/runtime/types";
-export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool";
+export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool" | "manual";
// optimization = 发送前的从容压缩(阈值更宽),protection = 运行中的保护性压缩(阈值更紧)。
export type CompactionIntent = "optimization" | "protection";
diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx
index 6974cae64..98f6a7465 100644
--- a/crates/agent-gui/src/pages/ChatPage.tsx
+++ b/crates/agent-gui/src/pages/ChatPage.tsx
@@ -34,6 +34,7 @@ import { useLocale } from "../i18n";
import type { AppUpdateController } from "../lib/appUpdates";
import { getAutomationState, useAutomation } from "../lib/automation";
import type { ChatFileLink } from "../lib/chat/chatFileLinks";
+import type { CompactionSinks } from "../lib/chat/compaction/controller";
import type { CompactionStatus } from "../lib/chat/compaction/types";
import {
buildRequestContext,
@@ -41,6 +42,8 @@ import {
createConversationStateFromContext,
type RenderTimelineItem,
} from "../lib/chat/conversation/conversationState";
+import { createGatewayBridgeEventController } from "../lib/chat/conversation/run/gatewayBridgeEvents";
+import { createTurnCancellation } from "../lib/chat/conversation/turnCancellation";
import type { ChatHistorySummary } from "../lib/chat/history/chatHistory";
import { memoryExtraction } from "../lib/chat/memory/extractionController";
import type { CodeMentionReference } from "../lib/chat/messages/mentionReferences";
@@ -54,6 +57,7 @@ import {
import type { ScrollFollowHandle } from "../lib/chat-scroll/useScrollFollow";
import { tauriGitClient } from "../lib/git/tauriGitClient";
import { setPreferredMonacoNlsLocale } from "../lib/monacoNls";
+import { createProviderRuntimeConfig } from "../lib/providers/llm";
import {
type AppSettings,
getRightDockFileTreeState,
@@ -127,6 +131,7 @@ import { appendManagedSkillSelections } from "./chat/chatPageUtils";
import { ChatFileDropOverlay } from "./chat/components/ChatFileDropOverlay";
import { WorkspaceOverlayHost } from "./chat/components/WorkspaceOverlayHost";
import { useComposerDraftCache } from "./chat/composer/useComposerDraftCache";
+import { createLocalGatewayChatRunId } from "./chat/gateway/gatewayRuntimeStatusModel";
import { useGatewayBridgeReadiness } from "./chat/gateway/useGatewayBridgeReadiness";
import { useGatewayRunMirrorCoordinator } from "./chat/gateway/useGatewayRunMirrorCoordinator";
import { useGatewayStatus } from "./chat/gateway/useGatewayStatus";
@@ -139,6 +144,11 @@ import {
removeQueuedChatTurnsForConversation,
} from "./chat/queue/chatTurnQueue";
import { useChatTurnQueue } from "./chat/queue/useChatTurnQueue";
+import { buildCompactionContext } from "./chat/runtime/conversationContextBuilders";
+import {
+ type EffectiveChatModelSelection,
+ resolveEffectiveChatModelSelection,
+} from "./chat/runtime/modelSelection";
import { useChatModelSelection } from "./chat/runtime/useChatModelSelection";
import { useSendChatTurn } from "./chat/runtime/useSendChatTurn";
import { ChatSidebarContainer } from "./chat/sidebar/ChatSidebarContainer";
@@ -368,6 +378,21 @@ export function ChatPage(props: ChatPageProps) {
() => conversationState.transcript.items,
[conversationState],
);
+ const contextUsageTokens = useMemo(() => {
+ for (let itemIndex = transcriptItems.length - 1; itemIndex >= 0; itemIndex -= 1) {
+ const item = transcriptItems[itemIndex];
+ if (item.kind === "summary") return undefined;
+ if (item.kind !== "assistant") continue;
+
+ for (let roundIndex = item.rounds.length - 1; roundIndex >= 0; roundIndex -= 1) {
+ const totalTokens = item.rounds[roundIndex].meta?.usageTotalTokens;
+ if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) {
+ return totalTokens;
+ }
+ }
+ }
+ return undefined;
+ }, [transcriptItems]);
// Sent-prompt history for the composer's ↑/↓ recall. Read lazily through a
// ref so the memoized composer bar never re-renders on transcript growth.
const transcriptItemsRef = useRef(transcriptItems);
@@ -1087,6 +1112,131 @@ export function ChatPage(props: ChatPageProps) {
setHydrationFailedConversationId,
});
+ /**
+ * 手动压缩当前会话上下文(由输入区上下文用量环的确认弹窗触发)。
+ * 空闲时复用压缩主流程强制压缩;发送中/压缩中返回 false。
+ */
+ const handleManualCompact = useCallback(async (): Promise => {
+ const conversationId = currentConversationIdRef.current;
+ if (isConversationRunning(conversationId)) return false;
+ const runtimeEntry = buildRuntimeEntryFromVisibleState();
+ if (runtimeEntry.compactionStatus.phase !== "idle") return false;
+ // 手动压缩仅在上下文占用超过 50% 时可用(与用量环展示口径一致)。
+ const usageRatio =
+ typeof contextUsageTokens === "number" &&
+ Number.isFinite(contextUsageTokens) &&
+ typeof currentModelContextWindow === "number" &&
+ Number.isFinite(currentModelContextWindow) &&
+ currentModelContextWindow > 0
+ ? contextUsageTokens / currentModelContextWindow
+ : 0;
+ if (usageRatio <= 0.5) return false;
+
+ let effectiveSelectedModel: EffectiveChatModelSelection;
+ try {
+ effectiveSelectedModel = resolveEffectiveChatModelSelection({
+ settings,
+ conversationSelectedModel: runtimeEntry.selectedModel,
+ });
+ } catch {
+ setErrorMessage("当前模型配置不可用,请重新选择模型后重试。");
+ return false;
+ }
+ const { selectedModel, provider, providerId, model } = effectiveSelectedModel;
+ const providerConfig = createProviderRuntimeConfig(
+ provider,
+ model,
+ chatRuntimeControlsForCurrentProvider,
+ settings.customSettings.providerIdentities,
+ );
+
+ const hasRemoteGatewayTarget =
+ settings.remote.enabled &&
+ settings.remote.gatewayUrl.trim() !== "" &&
+ settings.remote.token.trim() !== "";
+ const gatewayBridgeEvents = createGatewayBridgeEventController({
+ conversationId,
+ requestId: createLocalGatewayChatRunId(conversationId),
+ workerId: "gui-live",
+ enabled: hasRemoteGatewayTarget,
+ sendEvent: queueGatewayBridgeEventForRequest,
+ flushEvents: flushGatewayBridgeEventsForRequest,
+ resolveErrorConversationId: () => currentConversationIdRef.current,
+ });
+ const setBridgeToolStatus = (status: string | null, isCompaction = false) => {
+ gatewayBridgeEvents.queueToolStatus(status, isCompaction);
+ updateToolStatus(status, liveTranscriptStore);
+ };
+
+ const controller = getCompactionController(conversationId);
+ const sinks: CompactionSinks = {
+ applyState: (state) =>
+ updateConversationRuntimeEntry(conversationId, (prev) => ({ ...prev, state })),
+ applyStateMidRun: (state) => {
+ updateConversationRuntimeEntry(conversationId, (prev) => ({ ...prev, state }));
+ resetLiveTranscript(liveTranscriptStore);
+ },
+ publishStatus: (status) =>
+ updateConversationRuntimeEntry(conversationId, (prev) => ({
+ ...prev,
+ compactionStatus: status,
+ })),
+ setBridgeToolStatus,
+ queueCheckpoint: (state) => gatewayBridgeEvents.queueCheckpoint(state),
+ persist: (state) =>
+ persistConversation({
+ conversationId,
+ sessionId: runtimeEntry.sessionId,
+ providerId,
+ model,
+ selectedModel,
+ cwd: displayedConversationWorkdir || undefined,
+ state,
+ fallbackTitle: t("chat.pendingTitle"),
+ createdAt: runtimeEntry.createdAt,
+ titlePromise: null,
+ }),
+ };
+
+ try {
+ return await controller.compactNow({
+ state: runtimeEntry.state,
+ providerId,
+ model,
+ runtime: providerConfig,
+ cancellation: createTurnCancellation(),
+ sinks,
+ buildPreparedContext: (state, tools, options) =>
+ buildCompactionContext(state, tools, options),
+ buildResumeContext: (state, resumeMessage, tools, options) => {
+ const base = buildCompactionContext(state, tools, options);
+ if (!resumeMessage) return base;
+ return { ...base, messages: [...base.messages, resumeMessage] };
+ },
+ });
+ } finally {
+ await gatewayBridgeEvents.close();
+ }
+ }, [
+ buildRuntimeEntryFromVisibleState,
+ chatRuntimeControlsForCurrentProvider,
+ contextUsageTokens,
+ currentConversationIdRef,
+ currentModelContextWindow,
+ displayedConversationWorkdir,
+ flushGatewayBridgeEventsForRequest,
+ getCompactionController,
+ isConversationRunning,
+ liveTranscriptStore,
+ persistConversation,
+ queueGatewayBridgeEventForRequest,
+ resetLiveTranscript,
+ settings,
+ t,
+ updateConversationRuntimeEntry,
+ updateToolStatus,
+ ]);
+
startNewConversationActionRef.current = startNewConversation;
openInitialActionRef.current = openConversationInitial;
loadEarlierHistoryActionRef.current = loadEarlierConversationHistory;
@@ -2037,6 +2187,9 @@ export function ChatPage(props: ChatPageProps) {
chatRuntimeControls={chatRuntimeControlsForCurrentProvider}
reasoningOptions={chatRuntimeReasoningOptions}
thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn}
+ contextUsageTokens={contextUsageTokens}
+ contextWindow={currentModelContextWindow}
+ onManualCompactConfirm={handleManualCompact}
gitClient={tauriGitClient}
workspaceActivityClient={tauriWorkspaceActivityClient}
onSend={handleSend}
diff --git a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx
index 1d03b5fdd..226c0ae4a 100644
--- a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx
+++ b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx
@@ -38,6 +38,7 @@ import {
Trash2,
} from "../../../components/icons";
import { Button } from "../../../components/ui/button";
+import { ConfirmActionPopover } from "../../../components/ui/confirm-action-popover";
import {
Select,
SelectContent,
@@ -96,6 +97,118 @@ function RuntimeControlTooltip(props: { label: string; children: ReactNode }) {
);
}
+function ContextUsageRing(props: {
+ totalTokens?: number;
+ contextWindow?: number;
+ locale: string;
+ totalLabel: string;
+ contextWindowLabel: string;
+ confirmTitle: string;
+ confirmLabel: string;
+ disabled?: boolean;
+ onConfirm?: (() => void) | (() => Promise);
+}) {
+ const {
+ totalTokens,
+ contextWindow,
+ locale,
+ totalLabel,
+ contextWindowLabel,
+ confirmTitle,
+ confirmLabel,
+ disabled,
+ onConfirm,
+ } = props;
+ if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
+ return null;
+ }
+
+ const normalizedTokens =
+ typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0
+ ? Math.floor(totalTokens)
+ : 0;
+ const rawPercentage = (normalizedTokens / contextWindow) * 100;
+ const displayedPercentage = Math.min(999, Math.round(rawPercentage));
+ const ringPercentage = Math.min(100, Math.max(0, rawPercentage));
+ const formattedTokens = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(
+ normalizedTokens,
+ );
+ const formattedWindow = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(
+ contextWindow,
+ );
+ const label = `${displayedPercentage}% · ${totalLabel} ${formattedTokens} · ${contextWindowLabel} ${formattedWindow}`;
+ const progressClass =
+ rawPercentage >= 90
+ ? "stroke-red-500 dark:stroke-red-400"
+ : rawPercentage >= 70
+ ? "stroke-amber-500 dark:stroke-amber-400"
+ : "stroke-emerald-500 dark:stroke-emerald-400";
+ // 手动压缩仅在上下文占用超过 50% 时可用。
+ const manualCompactAvailable = rawPercentage > 50;
+
+ return (
+
+ onConfirm?.()}
+ >
+ {(open) => (
+
+ )}
+
+
+ );
+}
let pendingComposerImagePreviewSequence = 0;
const pendingComposerImagePreviewKeys = new WeakMap();
@@ -184,6 +297,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
chatRuntimeControls: ChatRuntimeControls;
reasoningOptions: ReasoningLevel[];
thinkingAlwaysOn: boolean;
+ contextUsageTokens?: number;
+ contextWindow?: number;
+ /** 点击上下文用量环触发手动压缩(确认弹窗由组件内部展示)。 */
+ onManualCompactConfirm?: (() => void) | (() => Promise);
gitClient?: GitClient | null;
gitWriteEnabled?: boolean;
gitDisabledMessage?: string;
@@ -219,6 +336,9 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
chatRuntimeControls,
reasoningOptions,
thinkingAlwaysOn,
+ contextUsageTokens,
+ contextWindow,
+ onManualCompactConfirm,
gitClient,
gitWriteEnabled = true,
gitDisabledMessage,
@@ -240,7 +360,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
onHeightChange,
approvalBar,
} = props;
- const { t } = useLocale();
+ const { t, locale } = useLocale();
const rootRef = useRef(null);
const attachmentListRef = useRef(null);
const previousPendingUploadCountRef = useRef(0);
@@ -981,6 +1101,17 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
+